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
+214 -68
View File
@@ -230,6 +230,9 @@ function pbrShader(
transmission: boolean,
textureInfo: ArrayLike<number>,
extensionInfo: ArrayLike<number>,
sharedOcclusion: boolean,
lightMask: number,
directToCanvas: boolean,
) {
const names = [
"baseTexture",
@@ -306,11 +309,11 @@ struct VertexOutput {
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
@group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
@group(0) @binding(8) var<storage, read> pointLights: array<vec4<f32>>;
@group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>;
@group(0) @binding(10) var<storage, read> directionalLights: array<vec4<f32>>;
${lightMask & 1 ? "@group(0) @binding(8) var<storage, read> pointLights: array<vec4<f32>>;" : ""}
${lightMask & 2 ? "@group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>;" : ""}
${lightMask & 4 ? "@group(0) @binding(10) var<storage, read> directionalLights: array<vec4<f32>>;" : ""}
${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""}
${names.map((name, slot) => (texture(slot) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d<f32>;` : "")).join("\n")}
${names.map((name, slot) => (texture(slot) && !(slot === 4 && sharedOcclusion) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d<f32>;` : "")).join("\n")}
${transmission ? "@group(2) @binding(0) var sceneColor: texture_2d<f32>;\n@group(2) @binding(1) var sceneSampler: sampler;" : ""}
fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
@@ -357,11 +360,10 @@ fn pow5(value: f32) -> f32 {
return squared * squared * value;
}
fn directContribution(normal: vec3<f32>, view: vec3<f32>, lightDirection: vec3<f32>, radiance: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
fn directContribution(normal: vec3<f32>, view: vec3<f32>, lightDirection: vec3<f32>, radiance: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
let nDotL = max(dot(normal, lightDirection), 0.0);
if (nDotL <= 0.0) { return vec3(0.0); }
let halfVector = normalize(lightDirection + view);
let nDotV = max(dot(normal, view), 0.0001);
let nDotH = max(dot(normal, halfVector), 0.0);
let vDotH = max(dot(view, halfVector), 0.0);
let fresnel = f0 + (vec3(1.0) - f0) * pow5(1.0 - vDotH);
@@ -372,11 +374,14 @@ fn directContribution(normal: vec3<f32>, view: vec3<f32>, lightDirection: vec3<f
let geometryV = nDotV / mix(nDotV, 1.0, alpha * 0.5);
let geometryL = nDotL / mix(nDotL, 1.0, alpha * 0.5);
let specular = distribution * geometryV * geometryL * fresnel / max(4.0 * nDotV * nDotL, 0.0001);
let diffuse = baseColor * (vec3(1.0) - fresnel) * (1.0 - metallic) / 3.14159265;
let diffuse = diffuseColor * (vec3(1.0) - fresnel);
return (diffuse + specular) * radiance * nDotL;
}
fn pointContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
${
lightMask & 1
? `
fn pointContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
let colorIntensity = pointLights[index * 2u];
let state = pointLights[index * 2u + 1u];
if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); }
@@ -385,10 +390,16 @@ fn pointContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3
let falloff = max(1.0 - distance / state.x, 0.0);
let radiance = colorIntensity.rgb * colorIntensity.a * falloff * falloff * 0.08;
let sourceRoughness = clamp(roughness + max(state.z, 0.0) / max(distance, 0.0001), roughness, 1.0);
return directContribution(normal, view, offset / max(distance, 0.0001), radiance, baseColor, metallic, sourceRoughness, f0);
return directContribution(normal, view, offset / max(distance, 0.0001), radiance, diffuseColor, nDotV, sourceRoughness, f0);
}
`
: ""
}
fn spotContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
${
lightMask & 2
? `
fn spotContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
let colorIntensity = spotLights[index * 2u];
let state = spotLights[index * 2u + 1u];
if (state.w == 0.0 || state.x <= 0.0) { return vec3(0.0); }
@@ -398,16 +409,27 @@ fn spotContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<
let cone = smoothstep(cos(state.z), cos(state.y), dot(direction, fromLight / max(distance, 0.0001)));
let falloff = max(1.0 - distance / state.x, 0.0);
let radiance = colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * 0.08;
return directContribution(normal, view, -fromLight / max(distance, 0.0001), radiance, baseColor, metallic, roughness, f0);
return directContribution(normal, view, -fromLight / max(distance, 0.0001), radiance, diffuseColor, nDotV, roughness, f0);
}
`
: ""
}
fn directionalContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
${
lightMask & 4
? `
fn directionalContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
let colorIntensity = directionalLights[index * 2u];
let state = directionalLights[index * 2u + 1u];
if (state.x == 0.0) { return vec3(0.0); }
let lightDirection = normalize(rotate(rotors[index], vec3<f32>(0.0, 0.0, 1.0)));
return directContribution(normal, view, lightDirection, colorIntensity.rgb * colorIntensity.a * 0.1, baseColor, metallic, roughness, f0);
return directContribution(normal, view, lightDirection, colorIntensity.rgb * colorIntensity.a * 0.1, diffuseColor, nDotV, roughness, f0);
}
`
: ""
}
${directToCanvas ? canvasOutputShader : ""}
@fragment
fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4<f32> {
@@ -432,6 +454,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l
${normalTexture ? `let tangent = normalize(input.tangent.xyz - normal * dot(input.tangent.xyz, normal)); let bitangent = cross(normal, tangent) * input.tangent.w; let mapped = ${sample(2)}.xyz * 2.0 - 1.0; normal = normalize(mat3x3<f32>(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));` : ""}
let view = normalize(cameraMatrices[4].xyz - input.world);
let nDotV = max(dot(normal, view), 0.0001);
let diffuseColor = base.rgb * (1.0 - metallic) / 3.14159265;
let specularWeight = extension2.z * ${sample(11)}.a;
let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb;
var f0 = ${specularGlossiness ? "packedMaterial.rgb * extension3.rgb" : "mix(dielectricColor, base.rgb, metallic)"};
@@ -448,30 +471,48 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l
let fresnelPower = pow5(1.0 - nDotV);
let ambient = vec3<f32>(bitcast<f32>(clusters[${compactAmbientStart}]), bitcast<f32>(clusters[${compactAmbientStart + 1}]), bitcast<f32>(clusters[${compactAmbientStart + 2}]));
var directLight = vec3<f32>(0.0);
${
lightMask & 1
? `
let pointCount = clusters[10];
if (pointCount <= ${compactLightLimit}u) {
for (var light = 0u; light < pointCount; light++) {
directLight += pointContribution(clusters[${compactPointStart}u + light], normal, view, input.world, base.rgb, metallic, roughness, f0);
directLight += pointContribution(clusters[${compactPointStart}u + light], normal, view, input.world, diffuseColor, nDotV, roughness, f0);
}
} else {
for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) {
directLight += pointContribution(light, normal, view, input.world, base.rgb, metallic, roughness, f0);
directLight += pointContribution(light, normal, view, input.world, diffuseColor, nDotV, roughness, f0);
}
}
`
: ""
}
${
lightMask & 2
? `
let spotCount = clusters[${compactSpotCount}];
if (spotCount <= ${compactLightLimit}u) {
for (var light = 0u; light < spotCount; light++) {
directLight += spotContribution(clusters[${compactSpotStart}u + light], normal, view, input.world, base.rgb, metallic, roughness, f0);
directLight += spotContribution(clusters[${compactSpotStart}u + light], normal, view, input.world, diffuseColor, nDotV, roughness, f0);
}
} else {
for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) {
directLight += spotContribution(light, normal, view, input.world, base.rgb, metallic, roughness, f0);
directLight += spotContribution(light, normal, view, input.world, diffuseColor, nDotV, roughness, f0);
}
}
for (var light = 0u; light < arrayLength(&directionalLights) / 2u; light++) {
directLight += directionalContribution(light, normal, view, base.rgb, metallic, roughness, f0);
`
: ""
}
let occlusion = mix(1.0, ${sample(4)}.r, extension2.w);
${
lightMask & 4
? `
for (var light = 0u; light < arrayLength(&directionalLights) / 2u; light++) {
directLight += directionalContribution(light, normal, view, diffuseColor, nDotV, roughness, f0);
}
`
: ""
}
let occlusion = mix(1.0, ${sharedOcclusion ? "packedMaterial" : sample(4)}.r, extension2.w);
let emissive = vec3(properties.z, properties.w, extra.x) * ${sample(3, "vec4(1.0)")}.rgb * extension1.w;
${sheen ? "let sheen = extension3.rgb * pow(1.0 - nDotV, mix(2.0, 8.0, extension3.w));" : ""}
${
@@ -508,7 +549,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l
color = mix(color, transmitted, transmissionAmount);`
: ""
}
return vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"});
return ${directToCanvas ? "canvasOutput(vec4(color * accent.a, 1.0))" : `vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"})`};
}`;
}
@@ -559,13 +600,23 @@ function effectFragment(kind: string, options: Record<string, unknown>) {
}`;
}
function toneMapping(toneMap: string) {
return toneMap === "reinhard"
? "color / (color + vec3(1.0))"
: toneMap === "linear"
? "clamp(color, vec3(0.0), vec3(1.0))"
: "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))";
}
// Match the intermediate rgba16float rounding before applying the same presentation transform.
const canvasOutputShader = /* wgsl */ `
fn canvasOutput(value: vec4<f32>) -> vec4<f32> {
let rounded = vec4(unpack2x16float(pack2x16float(value.rg)), unpack2x16float(pack2x16float(value.ba)));
let color = max(rounded.rgb, vec3(0.0));
return vec4(${toneMapping("aces")}, rounded.a);
}`;
function presentShader(toneMap: string, composite: "scene" | undefined) {
const tone =
toneMap === "reinhard"
? "color / (color + vec3(1.0))"
: toneMap === "linear"
? "clamp(color, vec3(0.0), vec3(1.0))"
: "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))";
return `${fullscreenVertex}
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var sourceSampler: sampler;
@@ -573,7 +624,7 @@ ${composite === "scene" ? "@group(0) @binding(2) var scene: texture_2d<f32>;" :
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
let value = textureSample(source, sourceSampler, input.uv);
let color = max(${composite === "scene" ? "value.rgb + textureSample(scene, sourceSampler, input.uv).rgb * (1.0 - value.a)" : "value.rgb"}, vec3(0.0));
return vec4(${tone}, ${composite ? "1.0" : "value.a"});
return vec4(${toneMapping(toneMap)}, ${composite ? "1.0" : "value.a"});
}`;
}
@@ -611,6 +662,8 @@ export class Scene {
readonly core: YawnCore;
readonly ready: Promise<this>;
readonly hdr: boolean;
/** Fuse tone mapping into opaque PBR draws when no linear-color consumers need HDR. */
readonly directToCanvas: boolean;
readonly clearColor: readonly [number, number, number, number];
#graphUpdates = Promise.resolve();
#renderGraph?: object;
@@ -635,6 +688,11 @@ export class Scene {
#signals?: Float32Array;
#arrays = new WeakMap<object, object>();
#views = new WeakMap<object, object>();
#lights = {
point: new Set<number>(),
spot: new Set<number>(),
directional: new Set<number>(),
};
constructor(
canvas: HTMLCanvasElement,
@@ -643,6 +701,7 @@ export class Scene {
debug?: boolean;
fps?: number;
hdr?: boolean;
directToCanvas?: boolean;
clearColor?: ArrayLike<number>;
} = {},
) {
@@ -653,6 +712,7 @@ export class Scene {
)
throw new TypeError("Scene clearColor must contain four finite numbers");
this.hdr = options.hdr ?? true;
this.directToCanvas = options.directToCanvas ?? false;
this.clearColor = clearColor as [number, number, number, number];
this.core = new YawnCore(canvas, {
arenaBytes: options.arenaBytes,
@@ -1171,6 +1231,21 @@ export class Scene {
return this.updateRenderGraph();
}
/** Light handles register their lifetime, not intensity; hot edits never select a new variant. */
setLight(
kind: "point" | "spot" | "directional",
id: number,
present: boolean,
) {
const lights = this.#lights[kind];
const hadType = lights.size > 0;
if (present) lights.add(id);
else lights.delete(id);
return hadType !== Boolean(lights.size)
? this.updateRenderGraph()
: Promise.resolve();
}
updateRenderGraph() {
this.markDirty(true);
if (this.#graphBatchDepth) {
@@ -1327,16 +1402,38 @@ export class Scene {
const hasTransmission = forwardDraws.some(
(draw) => this.array("materialExtensions").row(draw.material)[0] > 0,
);
// Custom compute can author light rows directly, so retain the generic shader in that case.
const lightMask = this.#computePasses.size
? 7
: (this.#lights.point.size ? 1 : 0) |
(this.#lights.spot.size ? 2 : 0) |
(this.#lights.directional.size ? 4 : 0);
const directToCanvas =
this.directToCanvas &&
this.hdr &&
!this.#computePasses.size &&
!this.#effects.size &&
!this.#shaders.size &&
!hasTransmission &&
forwardDraws.length > 0 &&
forwardDraws.every(
(draw) =>
(Math.round(this.array("materials").row(draw.material)[11]) & 3) !==
2 &&
draw.geometry.data.has("normals") &&
draw.geometry.data.has("uvs"),
);
const compositeSceneColor =
hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0;
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
addTexture({
id: "hdr",
format: hdrFormat,
size: ["canvas", "canvas", 1],
usage: ["render", "sampled"],
transient: false,
});
if (!directToCanvas)
addTexture({
id: "hdr",
format: hdrFormat,
size: ["canvas", "canvas", 1],
usage: ["render", "sampled"],
transient: false,
});
addTexture({
id: "depth",
format: "depth24plus",
@@ -1374,6 +1471,31 @@ export class Scene {
let previous = computeIds.length ? computeIds : ["cluster-lights"];
const bucketRows = new Map<number, number[][]>();
if (directToCanvas) {
// Shares attachments with the forward draws, so core merges this constant background
// into their physical render pass. Use the same HDR rounding/tone mapping for the clear.
renderPipelines.push({
id: "canvas-background",
code: `${fullscreenVertex}\n${canvasOutputShader}\n@fragment fn fragment() -> @location(0) vec4<f32> { return canvasOutput(vec4<f32>(${this.clearColor.map((value) => (Number.isInteger(value) ? `${value}.0` : String(value))).join(", ")})); }`,
vertex: { entry: "vertex" },
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
depthStencil: {
format: "depth24plus",
depth_write_enabled: false,
depth_compare: "always",
},
});
passes.push({
id: "canvas-background",
type: "render",
pipeline: "canvas-background",
after: previous,
color: [{ resource: "canvas", clear: [0, 0, 0, 0] }],
depth: { resource: "depth", clear: 1 },
draw: { vertices: 3 },
});
previous = ["canvas-background"];
}
if (!forwardDraws.length) {
renderPipelines.push({
id: "empty-forward",
@@ -1435,10 +1557,14 @@ export class Scene {
sync: "manual",
});
const forwardPipelines = new Map<string, string>();
let firstColor = true;
let firstDepth = true;
let firstColor = !directToCanvas;
let firstDepth = !directToCanvas;
let sceneColorReady = false;
let forwardTarget = compositeSceneColor ? "scene-color" : "hdr";
let forwardTarget = directToCanvas
? "canvas"
: compositeSceneColor
? "scene-color"
: "hdr";
const attachments = () => {
const color = firstColor
? { clear: [...this.clearColor] }
@@ -1518,6 +1644,13 @@ export class Scene {
if (extensionInfo[11] <= 0) mask &= ~(1 << 4);
if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3);
const textureInfo = this.array("materialTextureInfo").row(material);
// All material samples currently use one sampler. Reuse only identical texture/UV reads.
const sharedOcclusion =
Boolean(mask & (1 << 1) && mask & (1 << 4)) &&
materialTextures[1] === materialTextures[4] &&
Array.from(textureInfo)
.slice(8, 15)
.every((value, lane) => value === textureInfo[32 + lane]);
let clearCompositeLayer = false;
if (transmitted || (flags & 3) === 2) flushBuckets();
if (transmitted && !sceneColorReady) {
@@ -1558,7 +1691,16 @@ export class Scene {
}
}
const code = detailed
? pbrShader(mask, flags, transmitted, textureInfo, extensionInfo)
? pbrShader(
mask,
flags,
transmitted,
textureInfo,
extensionInfo,
sharedOcclusion,
lightMask,
directToCanvas,
)
: basicForwardShader;
const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`;
let pipeline = forwardPipelines.get(pipelineKey);
@@ -1583,7 +1725,7 @@ export class Scene {
entry: "fragment",
targets: [
{
format: hdrFormat,
format: directToCanvas ? "canvas" : hdrFormat,
...((flags & 3) === 2
? {
blend: {
@@ -1644,7 +1786,7 @@ export class Scene {
binding: 10,
resource: "directional-lights",
},
]
].filter((_, index) => lightMask & (1 << index))
: []),
...(detailed && mask
? [
@@ -1656,7 +1798,9 @@ export class Scene {
: "material-linear",
},
...materialTextures.flatMap((value, lane) =>
value && (mask & (1 << lane)) !== 0
value &&
(mask & (1 << lane)) !== 0 &&
!(lane === 4 && sharedOcclusion)
? [
{
group: 1,
@@ -1770,32 +1914,34 @@ export class Scene {
previous = [pipeline];
}
const toneMap = String(
[...this.#effects.values()].find(
(effect) => effect.kind === "colorGrading",
)?.options.toneMap ?? "aces",
);
renderPipelines.push({
id: "present",
code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined),
vertex: { entry: "vertex" },
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
});
passes.push({
id: "present",
type: "render",
pipeline: "present",
after: previous,
bindings: [
{ group: 0, binding: 0, resource: input },
{ group: 0, binding: 1, resource: "linear" },
...(compositeSceneColor
? [{ group: 0, binding: 2, resource: "scene-color" }]
: []),
],
color: [{ resource: "canvas", clear: [...this.clearColor] }],
draw: { vertices: 3 },
});
if (!directToCanvas) {
const toneMap = String(
[...this.#effects.values()].find(
(effect) => effect.kind === "colorGrading",
)?.options.toneMap ?? "aces",
);
renderPipelines.push({
id: "present",
code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined),
vertex: { entry: "vertex" },
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
});
passes.push({
id: "present",
type: "render",
pipeline: "present",
after: previous,
bindings: [
{ group: 0, binding: 0, resource: input },
{ group: 0, binding: 1, resource: "linear" },
...(compositeSceneColor
? [{ group: 0, binding: 2, resource: "scene-color" }]
: []),
],
color: [{ resource: "canvas", clear: [...this.clearColor] }],
draw: { vertices: 3 },
});
}
const graph = {
id: "scene",
@@ -10,6 +10,7 @@ export class DirectionalLight extends Node {
this.ready = nodeReady.then(async () => {
const array = await scene.ensureRows("directionalLights", this.id + 1, 32, "f32");
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, 1, 0, 0, 0]);
await scene.setLight("directional", this.id, true);
return this;
});
}
@@ -21,6 +22,7 @@ export class DirectionalLight extends Node {
await this.ready;
if (this.disposed) return;
this.scene.array("directionalLights").row(this.id).fill(0);
await this.scene.setLight("directional", this.id, false);
await super.dispose();
}
}
+2
View File
@@ -29,6 +29,7 @@ export class PointLight extends Node {
options.radius ?? 0,
0,
]);
await scene.setLight("point", this.id, true);
return this;
});
}
@@ -62,6 +63,7 @@ export class PointLight extends Node {
await this.ready;
if (this.disposed) return;
this.scene.array("pointLights").row(this.id).fill(0);
await this.scene.setLight("point", this.id, false);
await super.dispose();
}
}
+2
View File
@@ -19,6 +19,7 @@ export class SpotLight extends Node {
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1,
]);
await scene.setLight("spot", this.id, true);
return this;
});
}
@@ -32,6 +33,7 @@ export class SpotLight extends Node {
await this.ready;
if (this.disposed) return;
this.scene.array("spotLights").row(this.id).fill(0);
await this.scene.setLight("spot", this.id, false);
await super.dispose();
}
}
+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 },
);