{
"maxTextureSize": 1024,
"models": [
{
"id": "scifi-helmet",
"name": "Sci-Fi Helmet",
"author": "Michael Pavlovic · glTF conversion by Norbert Nopper",
"license": "CC0-1.0",
"source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/SciFiHelmet",
"buffers": [
{
"uri": "geometry0.bin",
"bytes": 3643848
}
],
"images": [
{
"name": "SciFiHelmet_BaseColor.png",
"role": "color",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/0.png",
"bytes": 787868,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/0.bc.ktx2",
"bytes": 806328,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/0.astc.ktx2",
"bytes": 916548,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "SciFiHelmet_MetallicRoughness.png",
"role": "data",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/1.png",
"bytes": 2832159,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/1.bc.ktx2",
"bytes": 1257847,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/1.astc.ktx2",
"bytes": 1346371,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "SciFiHelmet_Normal.png",
"role": "normal",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/2.png",
"bytes": 2312403,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/2.bc.ktx2",
"bytes": 1097938,
"format": "bc5-rg-unorm"
},
"astc": {
"uri": "textures/2.astc.ktx2",
"bytes": 1187327,
"format": "astc-4x4-unorm"
}
}
}
],
"variants": {
"png": {
"uri": "png.gltf",
"bytes": 9578302
},
"bc": {
"uri": "bc.gltf",
"bytes": 6808000
},
"astc": {
"uri": "astc.gltf",
"bytes": 7096139
}
}
},
{
"id": "flight-helmet",
"name": "Flight Helmet",
"author": "Microsoft · glTF conversion by Gary Hsu",
"license": "CC0-1.0",
"source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/FlightHelmet",
"buffers": [
{
"uri": "geometry0.bin",
"bytes": 3227148
}
],
"images": [
{
"name": "FlightHelmet_Materials_RubberWoodMat_Normal.png",
"role": "normal",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/0.png",
"bytes": 1481275,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/0.bc.ktx2",
"bytes": 949458,
"format": "bc5-rg-unorm"
},
"astc": {
"uri": "textures/0.astc.ktx2",
"bytes": 1064017,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_RubberWoodMat_OcclusionRoughMetal.png",
"role": "data",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/1.png",
"bytes": 1596853,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/1.bc.ktx2",
"bytes": 992596,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/1.astc.ktx2",
"bytes": 1149300,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_RubberWoodMat_BaseColor.png",
"role": "color",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/2.png",
"bytes": 870375,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/2.bc.ktx2",
"bytes": 887964,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/2.astc.ktx2",
"bytes": 999978,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_GlassPlasticMat_Normal.png",
"role": "normal",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/3.png",
"bytes": 1137609,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/3.bc.ktx2",
"bytes": 905502,
"format": "bc5-rg-unorm"
},
"astc": {
"uri": "textures/3.astc.ktx2",
"bytes": 954338,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_GlassPlasticMat_OcclusionRoughMetal.png",
"role": "data",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/4.png",
"bytes": 1747745,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/4.bc.ktx2",
"bytes": 1025734,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/4.astc.ktx2",
"bytes": 1184584,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_GlassPlasticMat_BaseColor.png",
"role": "color",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/5.png",
"bytes": 576365,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/5.bc.ktx2",
"bytes": 661950,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/5.astc.ktx2",
"bytes": 716988,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_MetalPartsMat_Normal.png",
"role": "normal",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/6.png",
"bytes": 1599102,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/6.bc.ktx2",
"bytes": 1026061,
"format": "bc5-rg-unorm"
},
"astc": {
"uri": "textures/6.astc.ktx2",
"bytes": 1126618,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_MetalPartsMat_OcclusionRoughMetal.png",
"role": "data",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/7.png",
"bytes": 1496394,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/7.bc.ktx2",
"bytes": 983306,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/7.astc.ktx2",
"bytes": 1095718,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_MetalPartsMat_BaseColor.png",
"role": "color",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/8.png",
"bytes": 1003785,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/8.bc.ktx2",
"bytes": 867092,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/8.astc.ktx2",
"bytes": 984909,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_LeatherPartsMat_Normal.png",
"role": "normal",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/9.png",
"bytes": 1988193,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/9.bc.ktx2",
"bytes": 1097364,
"format": "bc5-rg-unorm"
},
"astc": {
"uri": "textures/9.astc.ktx2",
"bytes": 1116408,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_LeatherPartsMat_OcclusionRoughMetal.png",
"role": "data",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/10.png",
"bytes": 1999940,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/10.bc.ktx2",
"bytes": 1075826,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/10.astc.ktx2",
"bytes": 1248534,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_LeatherPartsMat_BaseColor.png",
"role": "color",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/11.png",
"bytes": 997718,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/11.bc.ktx2",
"bytes": 979782,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/11.astc.ktx2",
"bytes": 1147802,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_LensesMat_Normal.png",
"role": "normal",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/12.png",
"bytes": 5810,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/12.bc.ktx2",
"bytes": 1473,
"format": "bc5-rg-unorm"
},
"astc": {
"uri": "textures/12.astc.ktx2",
"bytes": 1649,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_LensesMat_OcclusionRoughMetal.png",
"role": "data",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/13.png",
"bytes": 794582,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/13.bc.ktx2",
"bytes": 662137,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/13.astc.ktx2",
"bytes": 714006,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "FlightHelmet_Materials_LensesMat_BaseColor.png",
"role": "color",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/14.png",
"bytes": 518777,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/14.bc.ktx2",
"bytes": 488584,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/14.astc.ktx2",
"bytes": 543451,
"format": "astc-4x4-unorm"
}
}
}
],
"variants": {
"png": {
"uri": "png.gltf",
"bytes": 21050357
},
"bc": {
"uri": "bc.gltf",
"bytes": 15840738
},
"astc": {
"uri": "astc.gltf",
"bytes": 17284239
}
}
},
{
"id": "toy-car",
"name": "Toy Car",
"author": "Guido Odendahl · scene by Eric Chadwick",
"license": "CC0-1.0",
"source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/ToyCar",
"buffers": [
{
"uri": "geometry0.bin",
"bytes": 3664368
}
],
"images": [
{
"name": "./ToyCar_normal.png",
"role": "normal",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/0.png",
"bytes": 143412,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/0.bc.ktx2",
"bytes": 94409,
"format": "bc5-rg-unorm"
},
"astc": {
"uri": "textures/0.astc.ktx2",
"bytes": 109584,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "./ToyCar_emissive.png",
"role": "color",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/1.png",
"bytes": 96859,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/1.bc.ktx2",
"bytes": 67938,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/1.astc.ktx2",
"bytes": 83179,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "./ToyCar_basecolor.png",
"role": "color",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/2.png",
"bytes": 196831,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/2.bc.ktx2",
"bytes": 184714,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/2.astc.ktx2",
"bytes": 193597,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "./ToyCar_occlusion_roughness_metallic.png",
"role": "data",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/3.png",
"bytes": 660788,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/3.bc.ktx2",
"bytes": 623267,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/3.astc.ktx2",
"bytes": 714062,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "./Fabric_normal.png",
"role": "normal",
"width": 512,
"height": 512,
"levels": 10,
"files": {
"png": {
"uri": "textures/4.png",
"bytes": 481706,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/4.bc.ktx2",
"bytes": 304732,
"format": "bc5-rg-unorm"
},
"astc": {
"uri": "textures/4.astc.ktx2",
"bytes": 329377,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "./Fabric_occlusion.png",
"role": "data",
"width": 1024,
"height": 1024,
"levels": 11,
"files": {
"png": {
"uri": "textures/5.png",
"bytes": 506944,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/5.bc.ktx2",
"bytes": 626004,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/5.astc.ktx2",
"bytes": 692737,
"format": "astc-4x4-unorm"
}
}
},
{
"name": "./Fabric_baseColor.png",
"role": "color",
"width": 512,
"height": 512,
"levels": 10,
"files": {
"png": {
"uri": "textures/6.png",
"bytes": 312844,
"format": "rgba8unorm"
},
"bc": {
"uri": "textures/6.bc.ktx2",
"bytes": 267094,
"format": "bc7-rgba-unorm"
},
"astc": {
"uri": "textures/6.astc.ktx2",
"bytes": 291617,
"format": "astc-4x4-unorm"
}
}
}
],
"variants": {
"png": {
"uri": "png.gltf",
"bytes": 6069511
},
"bc": {
"uri": "bc.gltf",
"bytes": 5838320
},
"astc": {
"uri": "astc.gltf",
"bytes": 6084329
}
}
}
]
}
// Compressed textures: choose a supported, pre-generated variant, then load it.
// The three glTF files share geometry and differ only in their image references.
// No encoder or runtime transcode is involved in these native-format KTX2 files.
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { ArrayBufferLoader } from "@woosh/meep-engine/src/engine/asset/loaders/ArrayBufferLoader.js";
import { ImageBitmapAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/image/ImageBitmapAssetLoader.js";
import { load_gltf } from "@woosh/meep-engine/src/shade/renderer/loader/gltf/load_gltf.js";
import { gpu_texture_compression_support } from "@woosh/meep-engine/src/shade/renderer/texture/format/gpu_texture_compression_support.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { ParentEntitySystem } from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntitySystem.js";
import { TransformAttachmentSystem } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachmentSystem.js";
import { prefab_compile } from "@woosh/meep-engine/src/engine/graphics3/prefab/prefab_compile.js";
import { prefab_instantiate, prefab_instance_destroy } from "@woosh/meep-engine/src/engine/graphics3/prefab/prefab_instantiate.js";
import { shade_bundle_bounds } from "@woosh/meep-engine/src/engine/graphics3/shade_bundle_bounds.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { AABB3 } from "@woosh/meep-engine/src/core/geom/3d/aabb/AABB3.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import { Camera } from "@woosh/meep-engine/src/engine/graphics/ecs/camera/Camera.js";
import { FORMATS, supportsFormat, chooseFormat, formatBytes, comparison } from "./formats.js";
import { textureStats } from "./textureStats.js";
import { prepareTextures } from "./prepareTextures.js";
import "./style.css";
const $ = id => document.getElementById(id);
const viewer = $("viewer");
const status = $("status");
function resetStats() {
for (const id of ["file-size", "gpu-size"]) $(id).textContent = "—";
for (const id of ["file-saving", "gpu-saving", "texture-detail"]) $(id).textContent = "";
$("file-bar").style.width = $("gpu-bar").style.width = "0%";
}
function showError(error) {
status.textContent = error.message;
status.dataset.state = "error";
viewer.setAttribute("aria-busy", "false");
$("retry").hidden = false;
}
async function start() {
// The renderer needs this WGSL feature as well as WebGPU. Give a useful
// message before entering engine startup on browsers below that floor.
if (!navigator.gpu?.wgslLanguageFeatures?.has("immediate_address_space")) {
throw new Error("This example needs WebGPU and WGSL immediate_address_space. Open it in a current Chrome or Edge browser with hardware acceleration enabled.");
}
if (!await navigator.gpu.requestAdapter()) throw new Error("No WebGPU adapter is available. Enable hardware acceleration and reload.");
const response = await fetch("./models/manifest.json");
if (!response.ok) throw new Error(`Could not load the model list (${response.status}).`);
const { models } = await response.json();
// 1. Bootstrap the normal glTF image/buffer loaders and drawing systems.
const engine = await EngineHarness.bootstrap({
configuration(config, engine) {
engine.graphics.on.contextFailed.add(failure => {
showError(new Error(`Could not start WebGPU. ${failure.message ?? failure}`));
});
config.addLoader(GameAssetType.ArrayBuffer, new ArrayBufferLoader());
config.addLoader(GameAssetType.ImageBitmap, new ImageBitmapAssetLoader());
config.addSystem(new ShadedGeometrySystem(engine.graphics, EngineHarness.shadeScene(engine)));
config.addSystem(new ParentEntitySystem());
config.addSystem(new TransformAttachmentSystem());
},
});
engine.settings.graphics_control_viewport_size.set(false);
viewer.prepend(engine.viewStack.el);
const resize = new ResizeObserver(() => engine.viewStack.size.set(viewer.clientWidth, viewer.clientHeight));
resize.observe(viewer);
await EngineHarness.buildBasics({
engine, enableTerrain: false, enableWater: false, enableLights: true,
enableShadows: true, shadowmapResolution: 2048,
focus: new Vector3(0, 0, 0), distance: 4.4,
pitch: 0.15, yaw: Math.PI + 0.3, cameraFarDistance: 50, showFps: false,
});
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
// Keep the engine's default environment, as in the First-person example.
engine.entityManager.dataset.traverseEntities([Light], light => {
if (light.type.getValue() === LightType.DIRECTION) light.intensity.set(6);
});
// 2. Only native formats enabled on this renderer's device are selectable.
const device = engine.graphics.renderer.device;
const support = gpu_texture_compression_support(device);
let format = chooseFormat(null, support);
let model = models[0];
let generation = 0;
let instance = null;
let root = null;
const dataset = engine.entityManager.dataset;
let cameraComponent;
dataset.traverseComponents(Camera, camera => {
if (camera.active.getValue()) cameraComponent = camera;
});
let modelRadius = 0;
// Fit Shade's shadow-cascade range after the orbit controller updates the view.
// Models are centered at the origin. A padded bounding sphere remains safe
// while orbiting, panning or zooming, without wasting cascades on empty space.
engine.graphics.on.preRender.add(() => {
const camera = engine.graphics.camera.camera;
const pose = camera.transform;
cameraComponent.clip_far = camera.far = Math.max(camera.near + 1,
Math.hypot(pose.translation_x, pose.translation_y, pose.translation_z) + modelRadius * 1.1);
camera.update();
});
// Nine possible selections, loaded only on demand. Reusing a compiled prefab
// also reuses its textures; repeated toggles never create duplicate GPU copies.
const loaded = new Map();
async function loadVariant(model, format) {
const url = `./models/${model.id}/${model.variants[format.id].uri}`;
if (!loaded.has(url)) {
const pending = load_gltf(url, { assetManager: engine.assetManager, texture_support: support })
.then(async bundle => {
const bounds = new AABB3();
if (!shade_bundle_bounds(bounds, bundle)) throw new Error("The model has no drawable geometry.");
await prepareTextures(bundle, engine.graphics.renderer);
return { prefab: prefab_compile(bundle), bounds, stats: textureStats(bundle) };
})
.catch(error => { loaded.delete(url); throw error; });
loaded.set(url, pending);
}
return loaded.get(url);
}
for (const item of models) {
const option = new Option(item.name, item.id);
$("model").add(option);
}
$("model").disabled = false;
for (const item of FORMATS) {
const label = document.createElement("label");
label.className = "format";
const input = document.createElement("input");
input.type = "radio";
input.name = "format";
input.value = item.id;
input.checked = item.id === format.id;
input.disabled = !supportsFormat(item, support);
label.classList.toggle("unavailable", input.disabled);
const text = document.createElement("span");
const title = document.createElement("strong");
title.textContent = item.label;
const detail = document.createElement("small");
detail.textContent = input.disabled ? "Unsupported by your GPU" : item.detail;
text.append(title, detail);
label.append(input, text);
$("formats").append(label);
input.addEventListener("change", () => {
if (input.checked && supportsFormat(item, support)) { format = item; select(); }
});
}
function clearModel() {
if (instance) prefab_instance_destroy(dataset, instance);
if (root) root.destroy();
instance = null;
root = null;
}
// 3. Load exactly the chosen variant. A sequence number prevents a slow old
// request from replacing a newer selection or publishing mismatched numbers.
async function select() {
const version = ++generation;
const selectedModel = model;
const selectedFormat = format;
// Keep the last complete frame on the canvas while the replacement loads.
// Texture uploads do not need the render loop to run.
engine.renderingEnabled = false;
viewer.setAttribute("aria-busy", "true");
status.dataset.state = "loading";
status.textContent = `Loading ${selectedModel.name} · ${selectedFormat.label}…`;
$("retry").hidden = true;
try {
const { prefab, bounds, stats } = await loadVariant(selectedModel, selectedFormat);
if (version !== generation) return;
clearModel();
// Normalize the display scale, leaving each model's authored geometry intact.
// All formats of a model use the same transform, camera and lighting.
const scale = 2.4 / Math.max(bounds.x1 - bounds.x0, bounds.y1 - bounds.y0, bounds.z1 - bounds.z0);
modelRadius = Math.hypot(bounds.x1 - bounds.x0, bounds.y1 - bounds.y0, bounds.z1 - bounds.z0) * scale / 2;
const transform = new Transform64();
transform.setScaleScalar(scale);
transform.setTranslation(-(bounds.x0 + bounds.x1) * scale / 2, -(bounds.y0 + bounds.y1) * scale / 2, -(bounds.z0 + bounds.z1) * scale / 2);
transform.updateMatrix();
root = new Entity().add(transform);
instance = prefab_instantiate(prefab, dataset, root.build(dataset));
engine.graphics.renderer.indicate_view_change();
// 4. File bytes come from the shipped manifest; GPU bytes come from the
// loaded material descriptors, including full mips and shared-image deduplication.
const fileBytes = selectedModel.variants[selectedFormat.id].bytes;
const pngBytes = selectedModel.variants.png.bytes;
$("file-size").textContent = formatBytes(fileBytes);
$("gpu-size").textContent = formatBytes(stats.bytes);
$("file-saving").textContent = comparison(fileBytes, pngBytes);
$("gpu-saving").textContent = comparison(stats.bytes, stats.rgbaBytes);
$("file-bar").style.width = `${Math.min(100, fileBytes / pngBytes * 100)}%`;
$("gpu-bar").style.width = `${Math.min(100, stats.bytes / stats.rgbaBytes * 100)}%`;
$("texture-detail").textContent = `${stats.count} textures · up to 1024 × 1024 · full mip chains`;
$("texture-detail").title = stats.formats.join(", ");
$("credit").textContent = `${selectedModel.author} · CC0`;
$("credit").href = selectedModel.source;
status.textContent = "";
status.dataset.state = "ready";
viewer.setAttribute("aria-busy", "false");
engine.renderingEnabled = true;
} catch (error) {
if (version === generation) {
engine.renderingEnabled = true;
showError(new Error(`Could not load ${selectedModel.name}. ${error.message}`));
}
}
}
$("model").addEventListener("change", () => { model = models.find(item => item.id === $("model").value); select(); });
$("retry").onclick = select;
device.lost.then(failure => {
++generation;
engine.renderingEnabled = false;
clearModel();
$("model").disabled = true;
for (const input of document.querySelectorAll('input[name="format"]')) input.disabled = true;
resetStats();
showError(new Error(`The GPU became unavailable. Reload to continue. ${failure.message ?? ""}`));
$("retry").textContent = "Reload";
$("retry").onclick = () => location.reload();
});
await select();
}
$("retry").onclick = () => location.reload();
start().catch(showError);
# Compressed textures
A small Meep viewer comparing **PNG**, **BC7 / BC5 in KTX2**, and **ASTC 4×4 in
KTX2**. It ships three models and all their texture variants. Select a model,
orbit or zoom, and switch formats without changing the camera or lighting.
Run from a repository checkout containing this example's `public/` folder.
The website's code-only source ZIP omits public assets; copy that folder from
the repository before running the downloaded source.
```sh
npm install
npm run dev
```
Open `/demo.html`. Use Node 24+ and a browser with WebGPU and WGSL
`immediate_address_space` (current Chrome or Edge with hardware acceleration).
`npm run build` writes the website demo to
`../../public/examples/compressed-textures/`; `npm test` checks the format
selection, asset manifest, texture payloads and memory accounting.
## Reading the example
- `src/main.js`: engine setup, checking the enabled GPU formats, loading a
selection, placing its prefab, and updating the readouts.
- `src/formats.js`: the three choices and the fallback policy.
- `src/textureStats.js`: memory accounting using Meep's upload descriptors.
- `src/prepareTextures.js`: preparing materials, uploading textures and completing PNG mipmaps.
- `public/models/manifest.json`: dimensions, mip counts, format names, paths,
measured file sizes, and model credits.
- `public/models/<model>/`: shared geometry, PNG textures, native KTX2 textures,
and a small glTF document for each choice.
### Select before loading
`gpu_texture_compression_support(renderer.device)` describes formats enabled on
the renderer's actual device. Adapter support alone is insufficient: optional
features must also have been enabled when the device was created.
The default is BC7 / BC5, then ASTC, then PNG. A BC selection requires **both**
BC7 and BC5. Unsupported choices remain visible and disabled. Devices with no
compression features, or only ETC2, use PNG; this example does not ship ETC2.
Only the selected model and texture variant are requested.
`load_gltf` produces a `SceneBundle`, which is compiled and instantiated using
Meep's prefab helpers. The native KTX2 image path produces `ShadeTexelData` and
uploads its existing blocks and mip levels. There is no runtime encoding or
Basis transcoding. Zstandard decompression unwraps the stored block bytes.
The compressed glTF documents use **Meep's native `image/ktx2` image support**.
They are engine-specific assets, not portable `KHR_texture_basisu` glTF files:
that extension specifies Basis payloads, not already-transcoded BC or ASTC.
The PNG documents are the portable glTF version. Do not declare
`KHR_texture_basisu` around native BC/ASTC payloads.
The cache holds at most the nine model/format combinations. Repeated switches
reuse their compiled prefabs and textures. Removing a placement removes its
entities, while the cached assets remain available. A request sequence prevents
an older load from replacing a newer selection. Failed loads can be retried.
While loading, `engine.renderingEnabled = false` holds the last complete frame
on the canvas. The viewer prepares materials, uploads textures and flushes PNG
mipmap generation, then waits for that GPU work to finish. Only then does it
replace the model and resume rendering. The canvas and existing readouts remain
visible throughout; a failed load resumes the previous model.
The viewer uses the engine's default environment and a shadow-casting sun.
Each model's scaled bounds determine a bounding sphere. Before rendering, the
camera's far distance is fitted to that sphere and the current camera position,
with a small margin, to keep CSM cascades concentrated around the model while
orbiting, panning and zooming. Format switches preserve this setup.
### What the readouts measure
**File size** sums the selected glTF, its shared geometry buffers, and its
texture files exactly once. These are bytes on disk, before HTTP compression;
the value does not change on a browser cache hit. Engine code, the manifest and
the default environment are excluded.
**GPU textures** is an estimate of the selected model's texture payload storage.
It is computed from the same texture descriptors Meep uses for upload, including
every mip level and rounding each compressed mip to whole blocks. Shared images
with identical descriptors count once. Geometry, render targets, cached
variants, allocation alignment and driver overhead are excluded. It is **not**
a measurement of the browser's total VRAM allocation.
PNG decodes to 4-byte-per-texel RGBA8. BC7, BC5 and ASTC 4×4 use 16 bytes per
4×4 block, about a quarter of RGBA8's storage at the same dimensions. The tiny
mip tails still occupy whole blocks. The GPU comparison uses the equivalent
RGBA8 chain, and each bar compares its own metric with PNG. The percentage is
the selected size divided by the PNG baseline: 25% means one quarter of its
size, and values above 100% indicate a larger file. File ratios vary with image
content and the unchanged geometry cost.
## Preparing textures offline
The checked-in assets are ready to use; building or opening this example does
not run conversion tools. They were prepared with **KTX-Software 4.4.2** and
**Sharp 0.34.5**. The source model revision and adaptations are recorded in
`public/models/ATTRIBUTION.md`.
All variants use identical geometry and the same PNG input images, capped at
1024 pixels on the longest edge, preserving aspect ratio. Color maps were
resized in linear light; data maps were resized without a color transfer.
Normals are linear data. Occlusion is packed in the R channel of ORM, with
roughness in G and metalness in B. Color and emissive textures use sRGB.
Example commands for one prepared **color** image:
```sh
toktx --t2 --genmipmap --assign_oetf srgb --assign_primaries bt709 --encode uastc --uastc_quality 2 intermediate.ktx2 color.png
ktx transcode --target bc7 --zstd 9 intermediate.ktx2 color.bc.ktx2
toktx --t2 --genmipmap --assign_oetf srgb --assign_primaries bt709 --encode astc --astc_blk_d 4x4 --astc_quality 60 --zcmp 9 color.astc.ktx2 color.png
```
For **ORM data**, use `--assign_oetf linear --assign_primaries none` instead of
the sRGB options. For **normals**, keep linear data and prepare BC5 as follows:
```sh
toktx --t2 --genmipmap --assign_oetf linear --assign_primaries none --encode uastc --uastc_quality 2 --normal_mode --normalize intermediate-normal.ktx2 normal.png
ktx transcode --target bc5 --zstd 9 intermediate-normal.ktx2 normal.bc.ktx2
toktx --t2 --genmipmap --assign_oetf linear --assign_primaries none --encode astc --astc_blk_d 4x4 --astc_quality 60 --normal_mode --normalize --input_swizzle rgb1 --zcmp 9 normal.astc.ktx2 normal.png
```
BC5 retains X/Y and Meep reconstructs Z. ASTC retains the RGB normal;
`--input_swizzle rgb1` prevents the encoder from packing Y into alpha.
The intermediate UASTC files are only build inputs and are not shipped.
Compressed mips are generated offline. PNG mips use the engine's runtime mip
filter, so distant differences can include mip filtering as well as compression.
When adding an asset, make a PNG glTF and two copies with matching image indices,
native texture URIs and `image/ktx2` MIME types. Keep geometry, materials and
samplers identical. Update the manifest's dimensions, full mip counts, actual
WebGPU format names and byte lengths, then run `npm test` and inspect it in a
browser. The tests decode every shipped KTX2 through Meep and verify the file
totals against the actual referenced files.
## Assets
Sci-Fi Helmet, Flight Helmet and Toy Car are CC0 assets from the Khronos glTF
sample collection. Full credits, source links, adaptations and the CC0 text are
in `public/models/`.