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:
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user