Render continuously and optimize the glTF viewer

Remove vsync and dirty-flag frame gating, optimize PBR sampling and lighting, add a direct-to-canvas loadout, and preserve camera state on viewer resize.

Add regression coverage and update rendering documentation.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b382-1e79-712b-abd1-d29201c9bb5e
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-09-18 08:58:27 +00:00
co-authored by heaust
parent bc957cf40d
commit 4784ad7e87
16 changed files with 573 additions and 178 deletions
+158
View File
@@ -4,6 +4,11 @@ import { Mesh } from "../src/Mesh";
import { FXAA } from "../src/PostProcesses/FXAA";
import { PBRMaterial } from "../src/materials/PBRMaterial";
import { Texture } from "../src/materials/Texture";
import { ShaderMaterial } from "../src/materials/ShaderMaterial";
import { PointLight } from "../src/lights/PointLight";
import { SpotLight } from "../src/lights/SpotLight";
import { DirectionalLight } from "../src/lights/DirectionalLight";
import { ComputePass } from "../src/ComputePass";
import {
bucketDraws,
createScene,
@@ -34,6 +39,11 @@ test("empty scenes use neutral authored defaults without placeholder geometry",
test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => {
const scene = await createScene();
await Promise.all([
new PointLight(scene).ready,
new SpotLight(scene).ready,
new DirectionalLight(scene).ready,
]);
const material = new PBRMaterial(scene);
await material.ready;
const mesh = detailedMesh(scene, material);
@@ -451,3 +461,151 @@ test("serialized render graphs compile into explicitly selectable loadouts", asy
assert.equal(loadoutId, `loadout-${before + 1}`);
assert.equal(core.loadouts.at(-1), loadoutId);
});
test("packed ORM reads are shared only for the same texture and UV transform", async () => {
const scene = await createScene();
const orm = new Texture(scene, { size: [1, 1, 1] });
const other = new Texture(scene, { size: [1, 1, 1] });
await Promise.all([orm.ready, other.ready]);
for (const [occlusion, info, shared] of [
[orm, {}, true],
[other, {}, false],
[orm, { texCoord: 1 }, false],
[orm, { offset: [0.25, -0.1] }, false],
[orm, { scale: [2, 1] }, false],
[orm, { rotation: 0.4 }, false],
] as const) {
const material = new PBRMaterial(scene, {
metallicRoughnessTexture: orm,
occlusionTexture: occlusion,
textureInfos: [{}, {}, {}, {}, info],
});
await material.ready;
const mesh = await detailedMesh(scene, material).ready;
const graph = latestGraph(scene);
const pass = forwardPasses(graph).at(-1);
const code = renderPipeline(graph, pass.pipeline).code;
assert.equal(code.includes("mix(1.0, packedMaterial.r"), shared);
assert.equal(code.includes("textureSample(occlusionTexture"), !shared);
assert.equal(
pass.bindings.some((b: any) => b.group === 1 && b.binding === 5),
!shared,
);
await mesh.dispose();
}
});
test("light variants follow first/last light lifetimes and keep hot edits rebuild-free", async () => {
const scene = await createScene();
const material = await new PBRMaterial(scene).ready;
await detailedMesh(scene, material).ready;
const code = () => pbrPipelines(latestGraph(scene))[0].code;
assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/);
const first = await new PointLight(scene, { intensity: 0 }).ready;
assert.match(code(), /fn pointContribution/);
assert.doesNotMatch(code(), /fn (spot|directional)Contribution/);
assert.equal(code().match(/let nDotV =/g)?.length, 1);
assert.match(code(), /let diffuseColor = base.rgb \* \(1.0 - metallic\)/);
const before = testCore(scene).graphs.length;
first.intensity = 3;
first.radius = 0.4;
const second = await new PointLight(scene).ready;
await first.dispose();
assert.equal(testCore(scene).graphs.length, before);
assert.match(code(), /fn pointContribution/);
const spot = await new SpotLight(scene).ready;
const directional = await new DirectionalLight(scene).ready;
assert.match(code(), /fn spotContribution/);
assert.match(code(), /fn directionalContribution/);
await second.dispose();
assert.doesNotMatch(code(), /fn pointContribution/);
await spot.dispose();
await directional.dispose();
assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/);
});
test("direct canvas loadout preserves clear/depth and falls back for linear-color consumers", async () => {
const scene = await createScene({
directToCanvas: true,
clearColor: [0.02, 0.09, 0.3, 0.5],
});
const material = await new PBRMaterial(scene).ready;
const mesh = await detailedMesh(scene, material).ready;
const assertDirect = () => {
const graph = latestGraph(scene);
assert.equal(
graph.resources.textures.some((t: any) => t.id === "hdr"),
false,
);
assert.equal(
graph.passes.some((p: any) => p.id === "present"),
false,
);
const background = graph.passes.find(
(p: any) => p.id === "canvas-background",
);
assert.deepEqual(background.depth, { resource: "depth", clear: 1 });
assert.match(
renderPipeline(graph, "canvas-background").code,
/canvasOutput\(vec4<f32>\(0.02, 0.09, 0.3, 0.5\)\)/,
);
const forward = forwardPasses(graph)[0];
assert.deepEqual(forward.color, [{ resource: "canvas", load: "load" }]);
assert.deepEqual(forward.depth, { resource: "depth", load: "load" });
assert.equal(pbrPipelines(graph)[0].fragment.targets[0].format, "canvas");
assert.match(pbrPipelines(graph)[0].code, /unpack2x16float\(pack2x16float/);
};
const assertHdr = () =>
assert.ok(renderPipeline(latestGraph(scene), "present"));
assertDirect();
const effect = new FXAA(scene);
await effect.ready;
assertHdr();
await effect.dispose();
assertDirect();
for (const options of [{ flags: 2 }, { transmission: 0.5 }]) {
const special = await new PBRMaterial(scene, options).ready;
const extra = await detailedMesh(scene, special).ready;
assertHdr();
await extra.dispose();
assertDirect();
}
const compute = new ComputePass({
code: "@compute @workgroup_size(1) fn main() {}",
});
await scene.addComputePass(compute);
assertHdr();
assert.match(
pbrPipelines(latestGraph(scene))[0].code,
/fn directionalContribution/,
);
await scene.removeComputePass(compute.id);
assertDirect();
const custom = new ShaderMaterial(scene, { code: "custom WGSL" });
await custom.ready;
assertHdr();
await custom.dispose();
assertDirect();
const basic = await new Mesh(scene, {
vertexData: { positions: triangle.positions, indices: triangle.indices },
}).ready;
assertHdr();
await basic.dispose();
assertDirect();
const mask = await new PBRMaterial(scene, { flags: 1 }).ready;
const masked = await detailedMesh(scene, mask).ready;
assertDirect();
assert.match(
pbrPipelines(latestGraph(scene)).find((p: any) =>
p.code.includes("discard"),
).code,
/canvasOutput/,
);
await masked.dispose();
await mesh.dispose();
assertHdr();
const ldr = await createScene({ directToCanvas: true, hdr: false });
const ldrMaterial = await new PBRMaterial(ldr).ready;
await detailedMesh(ldr, ldrMaterial).ready;
assert.ok(renderPipeline(latestGraph(ldr), "present"));
});
+57 -1
View File
@@ -28,7 +28,9 @@ const root = resolve(directory, "../../../..");
const baselines = resolve(
process.env.YAWN_SCREENSHOT_BASELINES ?? join(directory, "baselines"),
);
const settleMilliseconds = Number(process.env.YAWN_SCREENSHOT_SETTLE_MS ?? 1200);
const settleMilliseconds = Number(
process.env.YAWN_SCREENSHOT_SETTLE_MS ?? 1200,
);
const output = resolve(
process.env.YAWN_SCREENSHOT_OUTPUT ?? join(directory, "output"),
);
@@ -310,6 +312,20 @@ before(
await startServer();
await browser("--webgpu", "--headed", "open", `${url}/view/gltf`);
await browser("set", "viewport", "1000", "720", "1");
await browser(
"eval",
`(async () => {
const { YawnCore } = await import("/pkg/core.js");
const onProfile = YawnCore.prototype.onProfile;
YawnCore.prototype.onProfile = function (listener) {
window.__viewerCore = this;
return onProfile.call(this, profile => {
window.__viewerProfile = profile;
listener(profile);
});
};
})()`,
);
},
{ timeout: 900_000 },
);
@@ -392,3 +408,43 @@ for (const model of models) {
{ timeout: 120_000 },
);
}
test(
"viewer rebuilds at the resized pixel size without losing camera pose",
async () => {
await browser(
"eval",
`(() => {
const canvas = document.querySelector("canvas");
canvas.dispatchEvent(new WheelEvent("wheel", { deltaY: -80, cancelable: true }));
window.__resizeCore = window.__viewerCore;
window.__resizePose = Array.from(__viewerCore.array("cameras").row(0));
})()`,
);
await browser("set", "viewport", "640", "480", "2");
await browser(
"wait",
"--fn",
`window.__viewerCore !== window.__resizeCore &&
window.__viewerProfile?.canvas.width === 1280 &&
window.__viewerProfile?.canvas.height === 960 &&
document.querySelector("#drop-prompt").hidden`,
);
const state = await browser(
"eval",
`(() => {
const row = Array.from(__viewerCore.array("cameras").row(0));
return {
size: [document.querySelector("canvas").width, document.querySelector("canvas").height],
aspect: row[1],
posePreserved: [0, 2, 3, 13, 14, 15, 16, 17, 18].every(i => row[i] === __resizePose[i]),
};
})()`,
);
assert.deepEqual(state.result.size, [1280, 960]);
assert.ok(Math.abs(state.result.aspect - 4 / 3) < 1e-6);
assert.equal(state.result.posePreserved, true);
await diagnostics();
},
{ timeout: 120_000 },
);