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:
+214
-68
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -161,7 +161,6 @@ impl Core {
|
||||
if names.iter().any(|name| self.store.borrow().uses_rows(name)) {
|
||||
self.refresh()?;
|
||||
}
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -219,7 +218,6 @@ impl Core {
|
||||
.borrow_mut()
|
||||
.delete_object(name, id)
|
||||
.map_err(JsError::new)?;
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -233,7 +231,6 @@ impl Core {
|
||||
.borrow()
|
||||
.sync_rows(name, first, count, gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -269,7 +266,6 @@ impl Core {
|
||||
.borrow_mut()
|
||||
.upload_texture(name, mip_level, image, gpu)
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -296,9 +292,6 @@ impl Core {
|
||||
.as_ref()
|
||||
.is_some_and(|gpu| gpu.timestamp_queries);
|
||||
self.render.set_profiling(enabled && supported);
|
||||
if enabled && supported {
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
}
|
||||
supported
|
||||
}
|
||||
|
||||
|
||||
+3
-16
@@ -108,7 +108,6 @@ impl RenderData {
|
||||
pending: HashMap::new(),
|
||||
};
|
||||
data.create_rows("signals".into(), 1, 32, "f32".into())?;
|
||||
data.write_signal(5, 1.0);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
@@ -321,25 +320,13 @@ impl RenderData {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_pending(&self) -> bool {
|
||||
self.signal(4) == 0.0 && self.signal(5) != 0.0 && self.signal(6) == 0.0
|
||||
}
|
||||
|
||||
pub fn begin_render(&mut self) -> bool {
|
||||
if !self.render_pending() {
|
||||
return false;
|
||||
}
|
||||
self.write_signal(5, 0.0);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn mark_dirty(&mut self) {
|
||||
self.write_signal(5, 1.0);
|
||||
pub fn render_allowed(&self) -> bool {
|
||||
// Lane 5 (legacy dirty flag) no longer gates continuous rendering.
|
||||
self.signal(4) == 0.0 && self.signal(6) == 0.0
|
||||
}
|
||||
|
||||
pub fn loadout_ready(&mut self) {
|
||||
self.write_signal(6, 0.0);
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
||||
fn signal(&self, index: usize) -> f32 {
|
||||
|
||||
+17
-15
@@ -1,34 +1,36 @@
|
||||
use super::RenderData;
|
||||
|
||||
#[test]
|
||||
fn dirty_signal_is_consumed_once() {
|
||||
fn rendering_does_not_require_a_dirty_signal() {
|
||||
let mut data = RenderData::new(64).unwrap();
|
||||
|
||||
assert!(data.rows("signals").is_some());
|
||||
assert!(data.begin_render());
|
||||
assert!(!data.begin_render());
|
||||
|
||||
data.mark_dirty();
|
||||
assert!(data.begin_render());
|
||||
assert!(!data.render_pending());
|
||||
for dirty in [0.0, 1.0, 0.0] {
|
||||
data.write_signal(5, dirty);
|
||||
assert!(data.render_allowed());
|
||||
data.update_signals(0.001, 2, 0.002, 0);
|
||||
assert!(data.render_allowed());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_and_bundle_signals_hold_dirty_work() {
|
||||
fn skip_and_bundle_signals_still_block_rendering() {
|
||||
let mut data = RenderData::new(64).unwrap();
|
||||
assert!(data.begin_render());
|
||||
data.write_signal(5, 0.0);
|
||||
assert!(data.render_allowed());
|
||||
|
||||
data.write_signal(4, 1.0);
|
||||
data.mark_dirty();
|
||||
assert!(!data.begin_render());
|
||||
assert!(!data.render_allowed());
|
||||
data.write_signal(4, 0.0);
|
||||
assert!(data.begin_render());
|
||||
assert!(data.render_allowed());
|
||||
|
||||
data.write_signal(6, 1.0);
|
||||
data.mark_dirty();
|
||||
assert!(!data.begin_render());
|
||||
assert!(!data.render_allowed());
|
||||
data.write_signal(4, 1.0);
|
||||
data.loadout_ready();
|
||||
assert!(data.begin_render());
|
||||
assert!(!data.render_allowed());
|
||||
data.write_signal(4, 0.0);
|
||||
assert!(data.render_allowed());
|
||||
}
|
||||
|
||||
fn rows_of(data: &RenderData, name: &str) -> u32 {
|
||||
|
||||
+41
-30
@@ -1,5 +1,7 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
@@ -14,22 +16,22 @@ const channel = new MessageChannel();
|
||||
const ready = [];
|
||||
channel.port1.onmessage = () => ready.shift()?.();
|
||||
|
||||
export function nextFrame() {
|
||||
return new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
|
||||
export function yieldTask() {
|
||||
return new Promise(resolve => {
|
||||
ready.push(resolve);
|
||||
channel.port2.postMessage(0);
|
||||
});
|
||||
}
|
||||
|
||||
export function idleTask() {
|
||||
return new Promise(resolve => setTimeout(resolve, 16));
|
||||
}
|
||||
"#)]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = nextFrame)]
|
||||
async fn next_frame();
|
||||
#[wasm_bindgen(js_name = yieldTask)]
|
||||
async fn yield_task();
|
||||
#[wasm_bindgen(js_name = idleTask)]
|
||||
async fn idle_task();
|
||||
}
|
||||
|
||||
pub struct RenderLoop {
|
||||
@@ -102,13 +104,15 @@ impl RenderLoop {
|
||||
let control = self.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
loop {
|
||||
next_frame().await;
|
||||
// Let control messages run without waiting for display vsync.
|
||||
yield_task().await;
|
||||
if !control.playing.get() {
|
||||
idle_task().await;
|
||||
continue;
|
||||
}
|
||||
let started = js_sys::Date::now();
|
||||
let fps = control.fps.get();
|
||||
if fps != 0 && started - control.last.get() < 900.0 / f64::from(fps) {
|
||||
if fps != 0 && started - control.last.get() < 1000.0 / f64::from(fps) {
|
||||
continue;
|
||||
}
|
||||
let delta = (started - control.last.replace(started)) / 1000.0;
|
||||
@@ -120,37 +124,40 @@ impl RenderLoop {
|
||||
let mut data = data.borrow_mut();
|
||||
data.update_signals(delta as f32, frame, elapsed as f32, control.fps.get());
|
||||
}
|
||||
let submission = if data.borrow().render_pending() {
|
||||
let submission = if data.borrow().render_allowed() {
|
||||
if let (Some(gpu), Some(loadout)) =
|
||||
(gpu.borrow_mut().as_mut(), store.borrow_mut().active_mut())
|
||||
{
|
||||
if data.borrow_mut().begin_render() {
|
||||
let profile = control.profiling.get()
|
||||
&& !control.profile_pending.get()
|
||||
&& started >= control.profile_after.get();
|
||||
let result = {
|
||||
let data = data.borrow();
|
||||
gpu.render(loadout, &data, profile)
|
||||
};
|
||||
match result {
|
||||
Ok(profile) => profile.map(|profile| {
|
||||
(profile, gpu.adapter.clone(), gpu.width, gpu.height)
|
||||
}),
|
||||
Err(_) => {
|
||||
data.borrow_mut().mark_dirty();
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let profile = control.profiling.get()
|
||||
&& !control.profile_pending.get()
|
||||
&& started >= control.profile_after.get();
|
||||
gpu.render(loadout, &data.borrow(), profile)
|
||||
.ok()
|
||||
.map(|profile| {
|
||||
let completed = Arc::new(AtomicBool::new(false));
|
||||
let callback = completed.clone();
|
||||
gpu.queue.on_submitted_work_done(move || {
|
||||
callback.store(true, Ordering::Release);
|
||||
});
|
||||
(
|
||||
profile,
|
||||
gpu.adapter.clone(),
|
||||
gpu.width,
|
||||
gpu.height,
|
||||
completed,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((profile, adapter, width, height)) = submission {
|
||||
let Some((profile, adapter, width, height, completed)) = submission else {
|
||||
idle_task().await;
|
||||
continue;
|
||||
};
|
||||
if let Some(profile) = profile {
|
||||
control.profile_pending.set(true);
|
||||
control.profile_after.set(started + 250.0);
|
||||
let control = control.clone();
|
||||
@@ -190,6 +197,10 @@ impl RenderLoop {
|
||||
control.profile_pending.set(false);
|
||||
});
|
||||
}
|
||||
// Bound GPU work in flight instead of queuing frames faster than it can draw.
|
||||
while !completed.load(Ordering::Acquire) {
|
||||
yield_task().await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,26 +48,20 @@ Capacity does not shrink. Deletion makes descriptors/views invalid for applicati
|
||||
| 2 | `elapsedTime` | Accumulated seconds |
|
||||
| 3 | `targetFps` | Configured cap; 0 means uncapped |
|
||||
| 4 | `skipRender` | Nonzero suppresses rendering |
|
||||
| 5 | `sabDirty` | Nonzero requests a frame; consumed (set to 0) at frame start |
|
||||
| 5 | `reserved` | Legacy `sabDirty` lane; ignored by the renderer |
|
||||
| 6 | `bundleDirty` | Nonzero suppresses stale bundles until a successful loadout switch |
|
||||
| 7 | `reserved` | No current contract; leave unchanged/zero |
|
||||
|
||||
The core updates lanes 0–3 while playing. A render starts only when lanes 4 and 6 are zero and lane 5 is nonzero. Finish the row writes first, then set the dirty signal:
|
||||
The core updates lanes 0–3 while playing and renders continuously when lanes 4 and 6 are zero. No dirty signal is needed. The worker yields to control messages and waits for GPU completion between submissions, rather than waiting for `requestAnimationFrame` or main-thread vsync. An explicit FPS cap still applies; the browser controls display presentation.
|
||||
|
||||
```js
|
||||
const signalsF32 = rowFor(buffer, signalDescriptor, 0);
|
||||
|
||||
function publishRows(write) {
|
||||
write(); // finish every row write
|
||||
signalsF32[5] = 1; // request the frame last
|
||||
}
|
||||
|
||||
async function replaceBundle(mutate, compileAndSwitch) {
|
||||
signalsF32[6] = 1; // suppress the old bundle first
|
||||
mutate();
|
||||
signalsF32[5] = 1;
|
||||
try {
|
||||
await compileAndSwitch(); // successful switch clears lane 6 and requests a frame
|
||||
await compileAndSwitch(); // successful switch clears lane 6 and resumes rendering
|
||||
} catch (error) {
|
||||
// Keep lane 6 set: rendering stale bindings would be unsafe. Repair/retry or stop.
|
||||
throw error;
|
||||
@@ -75,12 +69,12 @@ async function replaceBundle(mutate, compileAndSwitch) {
|
||||
}
|
||||
```
|
||||
|
||||
The signals are invalidation state, not a lock or transaction boundary. If multiple threads can write one logical update, coordinate those writers so core cannot observe a partially updated value. Core marks lane 5 after object deletion, texture upload, and a successful switch, but direct SAB writes do not. Always publish lane 5 after direct data writes.
|
||||
The signals are control state, not a lock or transaction boundary. Shared row writes can overlap worker reads; callers needing an atomic multi-field update should await `pause`, write the rows, then `play`.
|
||||
|
||||
## Frame-sync versus manual-sync
|
||||
|
||||
A graph buffer's `sync` defaults to `"frame"`. Before each actual render, the full named row array is copied SAB → GPU, so ordinary value edits only require `sabDirty`. Use it for small, frequently changing arrays (transforms, lights, draw commands).
|
||||
A graph buffer's `sync` defaults to `"frame"`. Before each actual render, the full named row array is copied SAB → GPU, so ordinary value edits need no notification. Use it for small, frequently changing arrays (transforms, lights, draw commands).
|
||||
|
||||
`sync: "manual"` is copied in full only when its GPU buffer is created: on the first `switch-loadout` that declares it, and whenever the source array grows/relocates or the declaration's usage changes. Later SAB edits reach the GPU only through `sync-rows { name, first, count }`, which uploads that row range into every active buffer bound to the array and marks the frame dirty. Use it for large, mostly static arrays such as vertex and index data: write the rows, then `sync-rows` exactly that span.
|
||||
`sync: "manual"` is copied in full only when its GPU buffer is created: on the first `switch-loadout` that declares it, and whenever the source array grows/relocates or the declaration's usage changes. Later SAB edits reach the GPU only through `sync-rows { name, first, count }`, which uploads that row range into every active buffer bound to the array. Use it for large, mostly static arrays such as vertex and index data: write the rows, then `sync-rows` exactly that span.
|
||||
|
||||
GPU resources survive graph switches when nothing about them changed: a buffer is reused when its declaration ID keeps the same source array, byte size, and usage; a render or compute pipeline is reused when a previous declaration had identical code and state (IDs may differ); non-transient textures with the same descriptor are kept. Bind groups and render bundles are always rebuilt. Structural changes affecting bindings, offsets, draw ranges, passes, pipelines, or attachment/resource declarations require bundle invalidation and a graph/loadout switch because render commands are precompiled into render bundles.
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ features:
|
||||
- title: Raw transport
|
||||
details: Start core/worker.js, correlate replies, and handle profiler events.
|
||||
- title: Shared rows
|
||||
details: Build typed views, allocate slots, and follow the dirty-lane protocol.
|
||||
details: Build typed views, allocate slots, and control continuous rendering.
|
||||
- title: Graph wire format
|
||||
details: Encode, compile, and activate complete WebGPU render and compute graphs.
|
||||
---
|
||||
@@ -27,7 +27,7 @@ The browser main thread owns the HTML canvas and starts the module worker. The w
|
||||
The application is responsible for three important contracts:
|
||||
|
||||
1. Correlate each reply by its `request` value; profiler messages are unsolicited.
|
||||
2. Finish shared-memory writes before setting `signals[5]` (`sabDirty`).
|
||||
2. Shared-memory value edits are rendered continuously; no `sabDirty` notification is needed. Pause before updates that must not overlap rendering.
|
||||
3. Before a mutation that invalidates compiled render bundles, set `signals[6]` (`bundleDirty`), make the mutation, compile and switch a replacement graph, then let successful `switch-loadout` clear lane 6.
|
||||
|
||||
This site describes the current wire API, not the higher-level wrapper API.
|
||||
|
||||
@@ -60,16 +60,16 @@ On row growth, consume the returned descriptor before the next access and commit
|
||||
| `GRAPH_BLEND`, `GRAPH_WRITE_MASK` | Invalid fragment blend or write-mask bits. |
|
||||
| `GRAPH_SAMPLER` | Invalid sampler enum/compare value. |
|
||||
| `GRAPH_LOAD_OP`, `GRAPH_STORE_OP` | Attachment operation is not allowed. |
|
||||
| `SURFACE` | Surface acquisition/recovery failed; core marks the SAB dirty to retry later. |
|
||||
| `SURFACE` | Surface acquisition/recovery failed; the continuous render loop retries later. |
|
||||
|
||||
Shader compilation, bind-layout mismatch, device limits, invalid upload extents/mips, and WebGPU validation are not all normalized to these codes. Validate graph inputs before switching and retain the previous graph source for recovery.
|
||||
|
||||
## Safe state transitions
|
||||
|
||||
1. Initialize once and cache descriptors.
|
||||
2. Create rows, allocate IDs, and initialize complete rows before publishing `sabDirty`.
|
||||
2. Create rows, allocate IDs, and initialize complete rows. Pause first if an update must not overlap rendering.
|
||||
3. Compile graphs before switching. Compilation stores a graph but does not validate every GPU-dependent property; activation can still fail.
|
||||
4. For bundle-invalidating mutation, set `bundleDirty` first. Leave it set after compile/switch failure, because that intentionally suppresses stale rendering. Repair and switch successfully; never clear it merely to hide an error.
|
||||
5. Upload textures with transfer ownership. Delete cached sources when no future loadout needs them.
|
||||
6. Pause when editor state should stop timing/render-loop progress. `pause` does not cancel requests or clear dirty lanes.
|
||||
6. Pause when editor state should stop timing/render-loop progress. `pause` does not cancel requests or clear control lanes.
|
||||
7. On worker failure, reject all pending requests and rebuild the entire worker/canvas/core state. Requests are not generally safe to replay because create, allocate, delete, switch, and texture operations may already have committed.
|
||||
|
||||
@@ -7,18 +7,18 @@ All fields below are top-level beside `type` and `request`. Except for `init` an
|
||||
| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[], features: string[] }` | Once only; initializes WASM/WebGPU. `features` lists optional device features that were available (`timestamp-query`, `indirect-first-instance`). |
|
||||
| `create-rows` | `name`, `rows`, `stride`, `format` | — | `{ rows: descriptor, commit: boolean }` | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. `commit: true` means the rows moved: copy the old bytes into the descriptor and send `commit-rows`. |
|
||||
| `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | `{ rows: descriptor[] (input order), commit: string[] }` | Nonempty. Sequential; in-place creations/growths survive a later failure, staged relocations are discarded. `commit` lists the arrays that moved. Active GPU resources refresh once for in-place growth. |
|
||||
| `commit-rows` | `names: string[]` | — | `undefined` | Publishes staged relocations after the caller copied them. Must name every staged array. Frees the old rows, refreshes bound GPU buffers once, sets `sabDirty`. While a relocation is staged the worker queues every other request until this arrives. |
|
||||
| `commit-rows` | `names: string[]` | — | `undefined` | Publishes staged relocations after the caller copied them. Must name every staged array. Frees the old rows and refreshes bound GPU buffers once. While a relocation is staged the worker queues every other request until this arrives. |
|
||||
| `delete-rows` | `name` | — | `undefined` | Not `signals`, not active slots, not referenced by active graph. |
|
||||
| `allocate-object` | `name`, `count: u32 = 1` | — | `{ id, rows: descriptor, commit: boolean }` | Not `signals`; `count` > 0; reserves `count` contiguous rows starting at `id`; may grow. `commit` as in `create-rows`. |
|
||||
| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must be the first row of an active span; the whole span is zeroed. |
|
||||
| `sync-rows` | `name`, `first: u32 = 0`, `count: u32` | — | `undefined` | Uploads rows `first .. first + count` of the array into every active GPU buffer bound to it (any `sync`) and sets `sabDirty`. `ROW_RANGE` when the span exceeds the array. No-op without an active loadout. |
|
||||
| `sync-rows` | `name`, `first: u32 = 0`, `count: u32` | — | `undefined` | Uploads rows `first .. first + count` of the array into every active GPU buffer bound to it (any `sync`). `ROW_RANGE` when the span exceeds the array. No-op without an active loadout. |
|
||||
| `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. |
|
||||
| `switch-loadout` | `id` | — | `undefined` | Builds GPU resources and activates stored graph. Clears `bundleDirty`, sets `sabDirty`. |
|
||||
| `upload-texture` | `name`, `mipLevel: u32`, `image: ImageBitmap` | `[image]` | `undefined` | Uploads immediately if active graph has the texture and caches source for future switches. Source extent must fit destination/mip. Sets dirty. |
|
||||
| `delete-texture` | `name` | — | `undefined` | Deletes/closes cached mip sources; does not remove graph texture or mark dirty. |
|
||||
| `switch-loadout` | `id` | — | `undefined` | Builds GPU resources and activates stored graph. Clears `bundleDirty`. |
|
||||
| `upload-texture` | `name`, `mipLevel: u32`, `image: ImageBitmap` | `[image]` | `undefined` | Uploads immediately if active graph has the texture and caches source for future switches. Source extent must fit destination/mip. |
|
||||
| `delete-texture` | `name` | — | `undefined` | Deletes/closes cached mip sources; does not remove graph texture. |
|
||||
| `play` | — | — | `undefined` | Enables render-loop ticks; resets last-time baseline. |
|
||||
| `pause` | — | — | `undefined` | Stops loop updates/renders. |
|
||||
| `set-fps` | `fps: u32` | — | `undefined` | 0 uncapped; maximum 1000. Does not itself dirty a frame. |
|
||||
| `set-fps` | `fps: u32` | — | `undefined` | 0 uncapped; maximum 1000. |
|
||||
| `set-profiler` | `enabled` (boolean-coerced) | — | boolean | Result says timestamp queries are supported. Enables only when requested and supported. |
|
||||
|
||||
Rust/WASM numeric conversion applies to `u32` fields; callers should send finite nonnegative integers in range rather than rely on coercion.
|
||||
@@ -41,6 +41,8 @@ When enabled and supported, the worker polls every 250 ms and may send an unsoli
|
||||
}
|
||||
```
|
||||
|
||||
`milliseconds` is the sum of GPU timestamp durations across passes, not CPU frame time or the interval between frames. It excludes scheduling waits and readback latency; `readbackMilliseconds` reports the time from starting that frame to receiving its timestamp results.
|
||||
|
||||
One pass timing corresponds to a compiled execution; compatible adjacent render passes are merged into one execution labelled `first … last (N draws)`. Samples are throttled and asynchronous, so they are diagnostics, not one event per frame. Disabling clears pending published statistics and the worker polling interval.
|
||||
|
||||
## Texture lifecycle
|
||||
|
||||
@@ -24,7 +24,11 @@ const scene = new Scene(canvas, { hdr: true, fps: 60 });
|
||||
await scene.ready;
|
||||
```
|
||||
|
||||
Omit `fps` to render as soon as shared data changes. `Scene` initializes conventional SOA rows and loads one clustered-forward HDR render graph. The core itself still starts with only its eight-float `signals` row.
|
||||
Omit `fps` for continuous, uncapped rendering. `Scene` initializes conventional SOA rows and loads one clustered-forward HDR render graph. The core itself still starts with only its eight-float `signals` row.
|
||||
|
||||
For a standalone viewer, `new Scene(canvas, { hdr: true, directToCanvas: true })` can fuse tone mapping into opaque or alpha-masked PBR draws, avoiding the HDR texture and separate presentation pass. It automatically retains the HDR path for blended/transmissive materials, post-processing, custom shaders or compute, and basic geometry without normals/UVs. Leave this option off when consuming the default HDR graph in a graph editor.
|
||||
|
||||
PBR shaders reuse packed occlusion/roughness/metallic samples when texture and UV transforms match. Point, spot, and directional light handles select shader variants when the first light of a type is created or the last is disposed; intensity, transform, and radius changes remain shared-memory writes. Custom compute passes retain all light types because they can author light rows themselves.
|
||||
|
||||
## 3. Add a triangle
|
||||
|
||||
|
||||
+53
-17
@@ -19,6 +19,7 @@ const elements = {
|
||||
|
||||
let activeViewer;
|
||||
let loading = false;
|
||||
let resizeTimer;
|
||||
|
||||
function setPrompt(message, detail, error = false) {
|
||||
elements.message.textContent = message;
|
||||
@@ -34,14 +35,17 @@ function resetPrompt() {
|
||||
);
|
||||
}
|
||||
|
||||
function canvasSize() {
|
||||
const scale = Math.min(devicePixelRatio || 1, 2);
|
||||
return [
|
||||
Math.max(1, Math.round(elements.dropZone.clientWidth * scale)),
|
||||
Math.max(1, Math.round(elements.dropZone.clientHeight * scale)),
|
||||
];
|
||||
}
|
||||
|
||||
function replaceCanvas() {
|
||||
const canvas = elements.canvas.cloneNode();
|
||||
const scale = Math.min(devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.max(1, Math.round(elements.dropZone.clientWidth * scale));
|
||||
canvas.height = Math.max(
|
||||
1,
|
||||
Math.round(elements.dropZone.clientHeight * scale),
|
||||
);
|
||||
[canvas.width, canvas.height] = canvasSize();
|
||||
elements.canvas.replaceWith(canvas);
|
||||
elements.canvas = canvas;
|
||||
return canvas;
|
||||
@@ -49,18 +53,12 @@ function replaceCanvas() {
|
||||
|
||||
function disposeViewer() {
|
||||
if (!activeViewer) return;
|
||||
cancelAnimationFrame(activeViewer.dirtyFrame);
|
||||
activeViewer.stopProfile?.();
|
||||
activeViewer.camera?.detachControls();
|
||||
activeViewer.scene.dispose();
|
||||
activeViewer = undefined;
|
||||
}
|
||||
|
||||
function markDirtyEveryFrame(viewer) {
|
||||
viewer.signals[5] = 1;
|
||||
viewer.dirtyFrame = requestAnimationFrame(() => markDirtyEveryFrame(viewer));
|
||||
}
|
||||
|
||||
function rotate([rx, ry, rz, rw], [x, y, z]) {
|
||||
const tx = 2 * (ry * z - rz * y);
|
||||
const ty = 2 * (rz * x - rx * z);
|
||||
@@ -189,7 +187,7 @@ function isGltf(file) {
|
||||
return /\.(?:glb|gltf)$/iu.test(file.name);
|
||||
}
|
||||
|
||||
async function openFile(file) {
|
||||
async function openFile(file, cameraState) {
|
||||
if (loading) return;
|
||||
if (!file || !isGltf(file)) {
|
||||
setPrompt("That file is not glTF", "Drop a .glb or .gltf file.", true);
|
||||
@@ -224,6 +222,7 @@ async function openFile(file) {
|
||||
scene = new Scene(canvas, {
|
||||
arenaBytes: 384 * 1024 * 1024,
|
||||
hdr: true,
|
||||
directToCanvas: true,
|
||||
clearColor: [0.025, 0.035, 0.045, 1],
|
||||
});
|
||||
await scene.ready;
|
||||
@@ -262,7 +261,13 @@ async function openFile(file) {
|
||||
];
|
||||
const camera = new ArcRotateCamera(
|
||||
scene,
|
||||
cameraOptions(scene, meshes, canvas, bounds),
|
||||
cameraState
|
||||
? {
|
||||
...cameraState,
|
||||
aspect: canvas.width / canvas.height,
|
||||
controls: { element: canvas, pointer: true },
|
||||
}
|
||||
: cameraOptions(scene, meshes, canvas, bounds),
|
||||
);
|
||||
await Promise.all([...lights.map((light) => light.ready), camera.ready]);
|
||||
|
||||
@@ -278,12 +283,11 @@ async function openFile(file) {
|
||||
else elements.renderTime.removeAttribute("title");
|
||||
|
||||
activeViewer = {
|
||||
file,
|
||||
camera,
|
||||
scene,
|
||||
stopProfile,
|
||||
signals: scene.core.array("signals").row(0),
|
||||
};
|
||||
markDirtyEveryFrame(activeViewer);
|
||||
resetPrompt();
|
||||
elements.prompt.hidden = true;
|
||||
} catch (error) {
|
||||
@@ -293,9 +297,37 @@ async function openFile(file) {
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
loading = false;
|
||||
scheduleResize();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleResize() {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (loading || !activeViewer) return;
|
||||
const [width, height] = canvasSize();
|
||||
if (elements.canvas.width === width && elements.canvas.height === height)
|
||||
return;
|
||||
const { file, camera, scene } = activeViewer;
|
||||
const row = scene.core.array("cameras").row(camera.cameraId);
|
||||
// Core's surface size is fixed at initialization. Recreate only after resizing settles,
|
||||
// preserving orbit, pan, zoom and clipping rather than asking for the file again.
|
||||
void openFile(file, {
|
||||
alpha: camera.alpha,
|
||||
beta: camera.beta,
|
||||
radius: camera.radius,
|
||||
targetPosition: Array.from(row.subarray(16, 19)),
|
||||
fov: camera.fov,
|
||||
near: camera.near,
|
||||
far: camera.far,
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleResize);
|
||||
resizeObserver.observe(elements.dropZone);
|
||||
addEventListener("resize", scheduleResize);
|
||||
|
||||
elements.prompt.addEventListener("click", () => {
|
||||
if (!loading) elements.filePicker.click();
|
||||
});
|
||||
@@ -326,5 +358,9 @@ elements.dropZone.addEventListener("drop", (event) => {
|
||||
void openFile(file);
|
||||
});
|
||||
|
||||
addEventListener("beforeunload", disposeViewer);
|
||||
addEventListener("beforeunload", () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeObserver.disconnect();
|
||||
disposeViewer();
|
||||
});
|
||||
resetPrompt();
|
||||
|
||||
Reference in New Issue
Block a user