Optimize viewer shaders and camera controls; sample GPU profiles asynchronously
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:
Generated
+10
@@ -117,6 +117,15 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -717,6 +726,7 @@ dependencies = [
|
|||||||
name = "yawn-core"
|
name = "yawn-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
+214
-68
@@ -230,6 +230,9 @@ function pbrShader(
|
|||||||
transmission: boolean,
|
transmission: boolean,
|
||||||
textureInfo: ArrayLike<number>,
|
textureInfo: ArrayLike<number>,
|
||||||
extensionInfo: ArrayLike<number>,
|
extensionInfo: ArrayLike<number>,
|
||||||
|
sharedOcclusion: boolean,
|
||||||
|
lightMask: number,
|
||||||
|
directToCanvas: boolean,
|
||||||
) {
|
) {
|
||||||
const names = [
|
const names = [
|
||||||
"baseTexture",
|
"baseTexture",
|
||||||
@@ -306,11 +309,11 @@ struct VertexOutput {
|
|||||||
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
|
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
|
||||||
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
|
@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(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
|
||||||
@group(0) @binding(8) var<storage, read> pointLights: array<vec4<f32>>;
|
${lightMask & 1 ? "@group(0) @binding(8) var<storage, read> pointLights: array<vec4<f32>>;" : ""}
|
||||||
@group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>;
|
${lightMask & 2 ? "@group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>;" : ""}
|
||||||
@group(0) @binding(10) var<storage, read> directionalLights: array<vec4<f32>>;
|
${lightMask & 4 ? "@group(0) @binding(10) var<storage, read> directionalLights: array<vec4<f32>>;" : ""}
|
||||||
${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""}
|
${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;" : ""}
|
${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> {
|
fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
|
||||||
@@ -357,11 +360,10 @@ fn pow5(value: f32) -> f32 {
|
|||||||
return squared * squared * value;
|
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);
|
let nDotL = max(dot(normal, lightDirection), 0.0);
|
||||||
if (nDotL <= 0.0) { return vec3(0.0); }
|
if (nDotL <= 0.0) { return vec3(0.0); }
|
||||||
let halfVector = normalize(lightDirection + view);
|
let halfVector = normalize(lightDirection + view);
|
||||||
let nDotV = max(dot(normal, view), 0.0001);
|
|
||||||
let nDotH = max(dot(normal, halfVector), 0.0);
|
let nDotH = max(dot(normal, halfVector), 0.0);
|
||||||
let vDotH = max(dot(view, halfVector), 0.0);
|
let vDotH = max(dot(view, halfVector), 0.0);
|
||||||
let fresnel = f0 + (vec3(1.0) - f0) * pow5(1.0 - vDotH);
|
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 geometryV = nDotV / mix(nDotV, 1.0, alpha * 0.5);
|
||||||
let geometryL = nDotL / mix(nDotL, 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 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;
|
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 colorIntensity = pointLights[index * 2u];
|
||||||
let state = pointLights[index * 2u + 1u];
|
let state = pointLights[index * 2u + 1u];
|
||||||
if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); }
|
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 falloff = max(1.0 - distance / state.x, 0.0);
|
||||||
let radiance = colorIntensity.rgb * colorIntensity.a * falloff * falloff * 0.08;
|
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);
|
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 colorIntensity = spotLights[index * 2u];
|
||||||
let state = spotLights[index * 2u + 1u];
|
let state = spotLights[index * 2u + 1u];
|
||||||
if (state.w == 0.0 || state.x <= 0.0) { return vec3(0.0); }
|
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 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 falloff = max(1.0 - distance / state.x, 0.0);
|
||||||
let radiance = colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * 0.08;
|
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 colorIntensity = directionalLights[index * 2u];
|
||||||
let state = directionalLights[index * 2u + 1u];
|
let state = directionalLights[index * 2u + 1u];
|
||||||
if (state.x == 0.0) { return vec3(0.0); }
|
if (state.x == 0.0) { return vec3(0.0); }
|
||||||
let lightDirection = normalize(rotate(rotors[index], vec3<f32>(0.0, 0.0, 1.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
|
@fragment
|
||||||
fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4<f32> {
|
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));` : ""}
|
${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 view = normalize(cameraMatrices[4].xyz - input.world);
|
||||||
let nDotV = max(dot(normal, view), 0.0001);
|
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 specularWeight = extension2.z * ${sample(11)}.a;
|
||||||
let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb;
|
let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb;
|
||||||
var f0 = ${specularGlossiness ? "packedMaterial.rgb * extension3.rgb" : "mix(dielectricColor, base.rgb, metallic)"};
|
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 fresnelPower = pow5(1.0 - nDotV);
|
||||||
let ambient = vec3<f32>(bitcast<f32>(clusters[${compactAmbientStart}]), bitcast<f32>(clusters[${compactAmbientStart + 1}]), bitcast<f32>(clusters[${compactAmbientStart + 2}]));
|
let ambient = vec3<f32>(bitcast<f32>(clusters[${compactAmbientStart}]), bitcast<f32>(clusters[${compactAmbientStart + 1}]), bitcast<f32>(clusters[${compactAmbientStart + 2}]));
|
||||||
var directLight = vec3<f32>(0.0);
|
var directLight = vec3<f32>(0.0);
|
||||||
|
${
|
||||||
|
lightMask & 1
|
||||||
|
? `
|
||||||
let pointCount = clusters[10];
|
let pointCount = clusters[10];
|
||||||
if (pointCount <= ${compactLightLimit}u) {
|
if (pointCount <= ${compactLightLimit}u) {
|
||||||
for (var light = 0u; light < pointCount; light++) {
|
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 {
|
} else {
|
||||||
for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) {
|
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}];
|
let spotCount = clusters[${compactSpotCount}];
|
||||||
if (spotCount <= ${compactLightLimit}u) {
|
if (spotCount <= ${compactLightLimit}u) {
|
||||||
for (var light = 0u; light < spotCount; light++) {
|
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 {
|
} else {
|
||||||
for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) {
|
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;
|
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));" : ""}
|
${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);`
|
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) {
|
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}
|
return `${fullscreenVertex}
|
||||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||||
@group(0) @binding(1) var sourceSampler: sampler;
|
@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> {
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
let value = textureSample(source, sourceSampler, input.uv);
|
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));
|
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 core: YawnCore;
|
||||||
readonly ready: Promise<this>;
|
readonly ready: Promise<this>;
|
||||||
readonly hdr: boolean;
|
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];
|
readonly clearColor: readonly [number, number, number, number];
|
||||||
#graphUpdates = Promise.resolve();
|
#graphUpdates = Promise.resolve();
|
||||||
#renderGraph?: object;
|
#renderGraph?: object;
|
||||||
@@ -635,6 +688,11 @@ export class Scene {
|
|||||||
#signals?: Float32Array;
|
#signals?: Float32Array;
|
||||||
#arrays = new WeakMap<object, object>();
|
#arrays = new WeakMap<object, object>();
|
||||||
#views = new WeakMap<object, object>();
|
#views = new WeakMap<object, object>();
|
||||||
|
#lights = {
|
||||||
|
point: new Set<number>(),
|
||||||
|
spot: new Set<number>(),
|
||||||
|
directional: new Set<number>(),
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
@@ -643,6 +701,7 @@ export class Scene {
|
|||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
fps?: number;
|
fps?: number;
|
||||||
hdr?: boolean;
|
hdr?: boolean;
|
||||||
|
directToCanvas?: boolean;
|
||||||
clearColor?: ArrayLike<number>;
|
clearColor?: ArrayLike<number>;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
@@ -653,6 +712,7 @@ export class Scene {
|
|||||||
)
|
)
|
||||||
throw new TypeError("Scene clearColor must contain four finite numbers");
|
throw new TypeError("Scene clearColor must contain four finite numbers");
|
||||||
this.hdr = options.hdr ?? true;
|
this.hdr = options.hdr ?? true;
|
||||||
|
this.directToCanvas = options.directToCanvas ?? false;
|
||||||
this.clearColor = clearColor as [number, number, number, number];
|
this.clearColor = clearColor as [number, number, number, number];
|
||||||
this.core = new YawnCore(canvas, {
|
this.core = new YawnCore(canvas, {
|
||||||
arenaBytes: options.arenaBytes,
|
arenaBytes: options.arenaBytes,
|
||||||
@@ -1171,6 +1231,21 @@ export class Scene {
|
|||||||
return this.updateRenderGraph();
|
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() {
|
updateRenderGraph() {
|
||||||
this.markDirty(true);
|
this.markDirty(true);
|
||||||
if (this.#graphBatchDepth) {
|
if (this.#graphBatchDepth) {
|
||||||
@@ -1327,16 +1402,38 @@ export class Scene {
|
|||||||
const hasTransmission = forwardDraws.some(
|
const hasTransmission = forwardDraws.some(
|
||||||
(draw) => this.array("materialExtensions").row(draw.material)[0] > 0,
|
(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 =
|
const compositeSceneColor =
|
||||||
hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0;
|
hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0;
|
||||||
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
|
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
|
||||||
addTexture({
|
if (!directToCanvas)
|
||||||
id: "hdr",
|
addTexture({
|
||||||
format: hdrFormat,
|
id: "hdr",
|
||||||
size: ["canvas", "canvas", 1],
|
format: hdrFormat,
|
||||||
usage: ["render", "sampled"],
|
size: ["canvas", "canvas", 1],
|
||||||
transient: false,
|
usage: ["render", "sampled"],
|
||||||
});
|
transient: false,
|
||||||
|
});
|
||||||
addTexture({
|
addTexture({
|
||||||
id: "depth",
|
id: "depth",
|
||||||
format: "depth24plus",
|
format: "depth24plus",
|
||||||
@@ -1374,6 +1471,31 @@ export class Scene {
|
|||||||
|
|
||||||
let previous = computeIds.length ? computeIds : ["cluster-lights"];
|
let previous = computeIds.length ? computeIds : ["cluster-lights"];
|
||||||
const bucketRows = new Map<number, number[][]>();
|
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) {
|
if (!forwardDraws.length) {
|
||||||
renderPipelines.push({
|
renderPipelines.push({
|
||||||
id: "empty-forward",
|
id: "empty-forward",
|
||||||
@@ -1435,10 +1557,14 @@ export class Scene {
|
|||||||
sync: "manual",
|
sync: "manual",
|
||||||
});
|
});
|
||||||
const forwardPipelines = new Map<string, string>();
|
const forwardPipelines = new Map<string, string>();
|
||||||
let firstColor = true;
|
let firstColor = !directToCanvas;
|
||||||
let firstDepth = true;
|
let firstDepth = !directToCanvas;
|
||||||
let sceneColorReady = false;
|
let sceneColorReady = false;
|
||||||
let forwardTarget = compositeSceneColor ? "scene-color" : "hdr";
|
let forwardTarget = directToCanvas
|
||||||
|
? "canvas"
|
||||||
|
: compositeSceneColor
|
||||||
|
? "scene-color"
|
||||||
|
: "hdr";
|
||||||
const attachments = () => {
|
const attachments = () => {
|
||||||
const color = firstColor
|
const color = firstColor
|
||||||
? { clear: [...this.clearColor] }
|
? { clear: [...this.clearColor] }
|
||||||
@@ -1518,6 +1644,13 @@ export class Scene {
|
|||||||
if (extensionInfo[11] <= 0) mask &= ~(1 << 4);
|
if (extensionInfo[11] <= 0) mask &= ~(1 << 4);
|
||||||
if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3);
|
if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3);
|
||||||
const textureInfo = this.array("materialTextureInfo").row(material);
|
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;
|
let clearCompositeLayer = false;
|
||||||
if (transmitted || (flags & 3) === 2) flushBuckets();
|
if (transmitted || (flags & 3) === 2) flushBuckets();
|
||||||
if (transmitted && !sceneColorReady) {
|
if (transmitted && !sceneColorReady) {
|
||||||
@@ -1558,7 +1691,16 @@ export class Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const code = detailed
|
const code = detailed
|
||||||
? pbrShader(mask, flags, transmitted, textureInfo, extensionInfo)
|
? pbrShader(
|
||||||
|
mask,
|
||||||
|
flags,
|
||||||
|
transmitted,
|
||||||
|
textureInfo,
|
||||||
|
extensionInfo,
|
||||||
|
sharedOcclusion,
|
||||||
|
lightMask,
|
||||||
|
directToCanvas,
|
||||||
|
)
|
||||||
: basicForwardShader;
|
: basicForwardShader;
|
||||||
const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`;
|
const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`;
|
||||||
let pipeline = forwardPipelines.get(pipelineKey);
|
let pipeline = forwardPipelines.get(pipelineKey);
|
||||||
@@ -1583,7 +1725,7 @@ export class Scene {
|
|||||||
entry: "fragment",
|
entry: "fragment",
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
format: hdrFormat,
|
format: directToCanvas ? "canvas" : hdrFormat,
|
||||||
...((flags & 3) === 2
|
...((flags & 3) === 2
|
||||||
? {
|
? {
|
||||||
blend: {
|
blend: {
|
||||||
@@ -1644,7 +1786,7 @@ export class Scene {
|
|||||||
binding: 10,
|
binding: 10,
|
||||||
resource: "directional-lights",
|
resource: "directional-lights",
|
||||||
},
|
},
|
||||||
]
|
].filter((_, index) => lightMask & (1 << index))
|
||||||
: []),
|
: []),
|
||||||
...(detailed && mask
|
...(detailed && mask
|
||||||
? [
|
? [
|
||||||
@@ -1656,7 +1798,9 @@ export class Scene {
|
|||||||
: "material-linear",
|
: "material-linear",
|
||||||
},
|
},
|
||||||
...materialTextures.flatMap((value, lane) =>
|
...materialTextures.flatMap((value, lane) =>
|
||||||
value && (mask & (1 << lane)) !== 0
|
value &&
|
||||||
|
(mask & (1 << lane)) !== 0 &&
|
||||||
|
!(lane === 4 && sharedOcclusion)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
group: 1,
|
group: 1,
|
||||||
@@ -1770,32 +1914,34 @@ export class Scene {
|
|||||||
previous = [pipeline];
|
previous = [pipeline];
|
||||||
}
|
}
|
||||||
|
|
||||||
const toneMap = String(
|
if (!directToCanvas) {
|
||||||
[...this.#effects.values()].find(
|
const toneMap = String(
|
||||||
(effect) => effect.kind === "colorGrading",
|
[...this.#effects.values()].find(
|
||||||
)?.options.toneMap ?? "aces",
|
(effect) => effect.kind === "colorGrading",
|
||||||
);
|
)?.options.toneMap ?? "aces",
|
||||||
renderPipelines.push({
|
);
|
||||||
id: "present",
|
renderPipelines.push({
|
||||||
code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined),
|
id: "present",
|
||||||
vertex: { entry: "vertex" },
|
code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined),
|
||||||
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
vertex: { entry: "vertex" },
|
||||||
});
|
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
||||||
passes.push({
|
});
|
||||||
id: "present",
|
passes.push({
|
||||||
type: "render",
|
id: "present",
|
||||||
pipeline: "present",
|
type: "render",
|
||||||
after: previous,
|
pipeline: "present",
|
||||||
bindings: [
|
after: previous,
|
||||||
{ group: 0, binding: 0, resource: input },
|
bindings: [
|
||||||
{ group: 0, binding: 1, resource: "linear" },
|
{ group: 0, binding: 0, resource: input },
|
||||||
...(compositeSceneColor
|
{ group: 0, binding: 1, resource: "linear" },
|
||||||
? [{ group: 0, binding: 2, resource: "scene-color" }]
|
...(compositeSceneColor
|
||||||
: []),
|
? [{ group: 0, binding: 2, resource: "scene-color" }]
|
||||||
],
|
: []),
|
||||||
color: [{ resource: "canvas", clear: [...this.clearColor] }],
|
],
|
||||||
draw: { vertices: 3 },
|
color: [{ resource: "canvas", clear: [...this.clearColor] }],
|
||||||
});
|
draw: { vertices: 3 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const graph = {
|
const graph = {
|
||||||
id: "scene",
|
id: "scene",
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export class ArcRotateCamera extends Camera {
|
|||||||
#controls?: ArcRotateControls;
|
#controls?: ArcRotateControls;
|
||||||
#pointer?: { x: number; y: number; button: number };
|
#pointer?: { x: number; y: number; button: number };
|
||||||
#frame = 0;
|
#frame = 0;
|
||||||
|
#inertiaFrame = 0;
|
||||||
|
#lastFrame = 0;
|
||||||
|
#motion = [0, 0, 0, 0, 0]; // orbit x/y, pan x/y, logarithmic zoom
|
||||||
|
|
||||||
constructor(scene: Scene, options: ArcRotateCameraOptions = {}) {
|
constructor(scene: Scene, options: ArcRotateCameraOptions = {}) {
|
||||||
super(scene, options);
|
super(scene, options);
|
||||||
@@ -116,6 +119,9 @@ export class ArcRotateCamera extends Camera {
|
|||||||
}
|
}
|
||||||
if (this.#frame) cancelAnimationFrame(this.#frame);
|
if (this.#frame) cancelAnimationFrame(this.#frame);
|
||||||
this.#frame = 0;
|
this.#frame = 0;
|
||||||
|
if (this.#inertiaFrame) cancelAnimationFrame(this.#inertiaFrame);
|
||||||
|
this.#inertiaFrame = 0;
|
||||||
|
this.#motion.fill(0);
|
||||||
this.#pointer = undefined;
|
this.#pointer = undefined;
|
||||||
this.#controls = undefined;
|
this.#controls = undefined;
|
||||||
}
|
}
|
||||||
@@ -161,7 +167,14 @@ export class ArcRotateCamera extends Camera {
|
|||||||
const y = event.clientY - this.#pointer.y;
|
const y = event.clientY - this.#pointer.y;
|
||||||
this.#pointer.x = event.clientX;
|
this.#pointer.x = event.clientX;
|
||||||
this.#pointer.y = event.clientY;
|
this.#pointer.y = event.clientY;
|
||||||
if (this.#pointer.button === 2) {
|
const offset = this.#pointer.button === 2 ? 2 : 0;
|
||||||
|
this.#motion[offset] += x;
|
||||||
|
this.#motion[offset + 1] += y;
|
||||||
|
this.#scheduleInertia();
|
||||||
|
};
|
||||||
|
|
||||||
|
#applyDrag(x: number, y: number, pan: boolean) {
|
||||||
|
if (pan) {
|
||||||
const row = this.cameraRow();
|
const row = this.cameraRow();
|
||||||
this.#updateTransform(() => {
|
this.#updateTransform(() => {
|
||||||
const element = this.#controls!.element;
|
const element = this.#controls!.element;
|
||||||
@@ -184,26 +197,46 @@ export class ArcRotateCamera extends Camera {
|
|||||||
row[18] -= horizontal * sinAlpha + vertical * cosBeta * cosAlpha;
|
row[18] -= horizontal * sinAlpha + vertical * cosBeta * cosAlpha;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const speed = this.#controls.orbitSpeed ?? 0.005;
|
const speed = this.#controls?.orbitSpeed ?? 0.005;
|
||||||
const row = this.cameraRow();
|
const row = this.cameraRow();
|
||||||
this.#updateTransform(() => {
|
this.#updateTransform(() => {
|
||||||
row[13] -= x * speed;
|
row[13] -= x * speed;
|
||||||
row[14] = Math.min(
|
row[14] = Math.min(
|
||||||
Math.PI - 0.001,
|
Math.PI - 0.001,
|
||||||
Math.max(0.001, row[14] + y * speed),
|
Math.max(0.001, row[14] - y * speed),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
#wheel = (event: WheelEvent) => {
|
#wheel = (event: WheelEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const row = this.cameraRow();
|
this.#motion[4] += event.deltaY * (this.#controls?.zoomSpeed ?? 0.001);
|
||||||
this.#updateTransform(() => {
|
this.#scheduleInertia();
|
||||||
row[15] = Math.max(
|
};
|
||||||
0.01,
|
|
||||||
row[15] * Math.exp(event.deltaY * (this.#controls?.zoomSpeed ?? 0.001)),
|
#scheduleInertia() {
|
||||||
);
|
if (this.#inertiaFrame) return;
|
||||||
|
this.#lastFrame = performance.now();
|
||||||
|
this.#inertiaFrame = requestAnimationFrame(this.#tickInertia);
|
||||||
|
}
|
||||||
|
|
||||||
|
#tickInertia = (now: number) => {
|
||||||
|
this.#inertiaFrame = 0;
|
||||||
|
const fraction = -Math.expm1(-Math.max(0, now - this.#lastFrame) / 80);
|
||||||
|
this.#lastFrame = now;
|
||||||
|
const step = this.#motion.map((remaining, index) => {
|
||||||
|
const value =
|
||||||
|
Math.abs(remaining) < 0.00001 ? remaining : remaining * fraction;
|
||||||
|
this.#motion[index] -= value;
|
||||||
|
return value;
|
||||||
});
|
});
|
||||||
|
this.batchTransformChanges(() => {
|
||||||
|
if (step[0] || step[1]) this.#applyDrag(step[0], step[1], false);
|
||||||
|
if (step[2] || step[3]) this.#applyDrag(step[2], step[3], true);
|
||||||
|
if (step[4]) this.radius *= Math.exp(step[4]);
|
||||||
|
});
|
||||||
|
if (this.#motion.some(Boolean))
|
||||||
|
this.#inertiaFrame = requestAnimationFrame(this.#tickInertia);
|
||||||
};
|
};
|
||||||
#pollController = () => {
|
#pollController = () => {
|
||||||
const pad = navigator.getGamepads?.().find(Boolean);
|
const pad = navigator.getGamepads?.().find(Boolean);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export class DirectionalLight extends Node {
|
|||||||
this.ready = nodeReady.then(async () => {
|
this.ready = nodeReady.then(async () => {
|
||||||
const array = await scene.ensureRows("directionalLights", this.id + 1, 32, "f32");
|
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]);
|
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;
|
return this;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ export class DirectionalLight extends Node {
|
|||||||
await this.ready;
|
await this.ready;
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.scene.array("directionalLights").row(this.id).fill(0);
|
this.scene.array("directionalLights").row(this.id).fill(0);
|
||||||
|
await this.scene.setLight("directional", this.id, false);
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export class PointLight extends Node {
|
|||||||
options.radius ?? 0,
|
options.radius ?? 0,
|
||||||
0,
|
0,
|
||||||
]);
|
]);
|
||||||
|
await scene.setLight("point", this.id, true);
|
||||||
return this;
|
return this;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,7 @@ export class PointLight extends Node {
|
|||||||
await this.ready;
|
await this.ready;
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.scene.array("pointLights").row(this.id).fill(0);
|
this.scene.array("pointLights").row(this.id).fill(0);
|
||||||
|
await this.scene.setLight("point", this.id, false);
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class SpotLight extends Node {
|
|||||||
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
||||||
options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1,
|
options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1,
|
||||||
]);
|
]);
|
||||||
|
await scene.setLight("spot", this.id, true);
|
||||||
return this;
|
return this;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ export class SpotLight extends Node {
|
|||||||
await this.ready;
|
await this.ready;
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.scene.array("spotLights").row(this.id).fill(0);
|
this.scene.array("spotLights").row(this.id).fill(0);
|
||||||
|
await this.scene.setLight("spot", this.id, false);
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,7 +292,31 @@ test("Node, ArcRotateCamera, and light handles update their shared rows", async
|
|||||||
assert.equal(scene.array("pointLights").row(light.id)[6], 3);
|
assert.equal(scene.array("pointLights").row(light.id)[6], 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ArcRotateCamera pans one projected world pixel per pointer pixel", async () => {
|
test("ArcRotateCamera damps pan, orbit and zoom with reversed vertical orbit", async (t) => {
|
||||||
|
let now = 0;
|
||||||
|
let next = 1;
|
||||||
|
const frames = new Map<number, FrameRequestCallback>();
|
||||||
|
const originalRequest = globalThis.requestAnimationFrame;
|
||||||
|
const originalCancel = globalThis.cancelAnimationFrame;
|
||||||
|
t.mock.method(performance, "now", () => now);
|
||||||
|
globalThis.requestAnimationFrame = (callback) => {
|
||||||
|
const id = next++;
|
||||||
|
frames.set(id, callback);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
globalThis.cancelAnimationFrame = (id) => {
|
||||||
|
frames.delete(id);
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.requestAnimationFrame = originalRequest;
|
||||||
|
globalThis.cancelAnimationFrame = originalCancel;
|
||||||
|
});
|
||||||
|
const advance = (ms: number) => {
|
||||||
|
now += ms;
|
||||||
|
const callbacks = [...frames.values()];
|
||||||
|
frames.clear();
|
||||||
|
for (const callback of callbacks) callback(now);
|
||||||
|
};
|
||||||
const listeners = new Map<string, Set<(event: any) => void>>();
|
const listeners = new Map<string, Set<(event: any) => void>>();
|
||||||
const element = {
|
const element = {
|
||||||
clientWidth: 400,
|
clientWidth: 400,
|
||||||
@@ -329,7 +353,43 @@ test("ArcRotateCamera pans one projected world pixel per pointer pixel", async (
|
|||||||
pointerId: 1,
|
pointerId: 1,
|
||||||
});
|
});
|
||||||
dispatch("pointermove", { clientX: 120, clientY: 110 });
|
dispatch("pointermove", { clientX: 120, clientY: 110 });
|
||||||
|
dispatch("pointerup", {});
|
||||||
|
advance(80);
|
||||||
|
close(camera.position, [-2 * (1 - Math.exp(-1)), 1 - Math.exp(-1), 10]);
|
||||||
|
advance(1600);
|
||||||
close(camera.position, [-2, 1, 10]);
|
close(camera.position, [-2, 1, 10]);
|
||||||
|
advance(16);
|
||||||
|
assert.equal(frames.size, 0);
|
||||||
|
const orbit = () => {
|
||||||
|
dispatch("pointerdown", {
|
||||||
|
button: 0,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100,
|
||||||
|
pointerId: 1,
|
||||||
|
});
|
||||||
|
dispatch("pointermove", { clientX: 140, clientY: 120 });
|
||||||
|
dispatch("pointerup", {});
|
||||||
|
};
|
||||||
|
orbit();
|
||||||
|
advance(40);
|
||||||
|
const released = camera.alpha;
|
||||||
|
advance(40);
|
||||||
|
assert.ok(camera.alpha < released);
|
||||||
|
const pose = [camera.alpha, camera.beta];
|
||||||
|
close(pose, [
|
||||||
|
-0.2 * (1 - Math.exp(-1)),
|
||||||
|
Math.PI / 2 - 0.1 * (1 - Math.exp(-1)),
|
||||||
|
]);
|
||||||
camera.detachControls();
|
camera.detachControls();
|
||||||
|
camera.alpha = 0;
|
||||||
|
camera.beta = Math.PI / 2;
|
||||||
|
camera.attachControls({ element });
|
||||||
|
orbit();
|
||||||
|
advance(80);
|
||||||
|
close([camera.alpha, camera.beta], pose);
|
||||||
|
dispatch("wheel", { deltaY: 100 });
|
||||||
|
advance(80);
|
||||||
|
close([camera.radius], [10 * Math.exp(0.1 * (1 - Math.exp(-1)))]);
|
||||||
|
camera.detachControls();
|
||||||
|
assert.equal(frames.size, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { Mesh } from "../src/Mesh";
|
|||||||
import { FXAA } from "../src/PostProcesses/FXAA";
|
import { FXAA } from "../src/PostProcesses/FXAA";
|
||||||
import { PBRMaterial } from "../src/materials/PBRMaterial";
|
import { PBRMaterial } from "../src/materials/PBRMaterial";
|
||||||
import { Texture } from "../src/materials/Texture";
|
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 {
|
import {
|
||||||
bucketDraws,
|
bucketDraws,
|
||||||
createScene,
|
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 () => {
|
test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => {
|
||||||
const scene = await createScene();
|
const scene = await createScene();
|
||||||
|
await Promise.all([
|
||||||
|
new PointLight(scene).ready,
|
||||||
|
new SpotLight(scene).ready,
|
||||||
|
new DirectionalLight(scene).ready,
|
||||||
|
]);
|
||||||
const material = new PBRMaterial(scene);
|
const material = new PBRMaterial(scene);
|
||||||
await material.ready;
|
await material.ready;
|
||||||
const mesh = detailedMesh(scene, material);
|
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(loadoutId, `loadout-${before + 1}`);
|
||||||
assert.equal(core.loadouts.at(-1), loadoutId);
|
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"));
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ crate-type = ["cdylib"]
|
|||||||
path = "lib.rs"
|
path = "lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
futures-channel = "0.3"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
+17
-6
@@ -1,3 +1,5 @@
|
|||||||
|
import { SharedProfile } from "./profile.js";
|
||||||
|
|
||||||
const views = Object.freeze({ f32: Float32Array, u32: Uint32Array, i32: Int32Array });
|
const views = Object.freeze({ f32: Float32Array, u32: Uint32Array, i32: Int32Array });
|
||||||
|
|
||||||
export class SharedRows {
|
export class SharedRows {
|
||||||
@@ -49,6 +51,8 @@ export class YawnCore {
|
|||||||
#arrays = new Map();
|
#arrays = new Map();
|
||||||
#pending = new Map();
|
#pending = new Map();
|
||||||
#profileListeners = new Set();
|
#profileListeners = new Set();
|
||||||
|
#profile = new SharedProfile();
|
||||||
|
#profileTimer;
|
||||||
#next = 1;
|
#next = 1;
|
||||||
/** Optional WebGPU features the device has (WebGPU spelling); populated once `ready` resolves. */
|
/** Optional WebGPU features the device has (WebGPU spelling); populated once `ready` resolves. */
|
||||||
features = new Set();
|
features = new Set();
|
||||||
@@ -64,7 +68,7 @@ export class YawnCore {
|
|||||||
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR"));
|
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR"));
|
||||||
this.#worker.start?.();
|
this.#worker.start?.();
|
||||||
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
|
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
|
||||||
this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(async result => {
|
this.ready = this.#request("init", { canvas: offscreen, arenaBytes, profileBuffer: this.#profile.buffer }, [offscreen]).then(async result => {
|
||||||
this.#buffer = result.buffer;
|
this.#buffer = result.buffer;
|
||||||
this.features = new Set(result.features);
|
this.features = new Set(result.features);
|
||||||
for (const descriptor of result.rows) this.#arrays.set(
|
for (const descriptor of result.rows) this.#arrays.set(
|
||||||
@@ -195,7 +199,17 @@ export class YawnCore {
|
|||||||
onProfile(listener) {
|
onProfile(listener) {
|
||||||
if (typeof listener !== "function") throw new TypeError("PROFILE_LISTENER");
|
if (typeof listener !== "function") throw new TypeError("PROFILE_LISTENER");
|
||||||
this.#profileListeners.add(listener);
|
this.#profileListeners.add(listener);
|
||||||
return () => this.#profileListeners.delete(listener);
|
if (!this.#profileTimer) this.#profileTimer = setInterval(() => {
|
||||||
|
const stats = this.#profile.read();
|
||||||
|
if (stats) for (const listener of this.#profileListeners) listener(stats);
|
||||||
|
}, 100);
|
||||||
|
return () => {
|
||||||
|
this.#profileListeners.delete(listener);
|
||||||
|
if (!this.#profileListeners.size) {
|
||||||
|
clearInterval(this.#profileTimer);
|
||||||
|
this.#profileTimer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
array(name) {
|
array(name) {
|
||||||
@@ -213,10 +227,6 @@ export class YawnCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#message(message) {
|
#message(message) {
|
||||||
if (message?.type === "profile") {
|
|
||||||
for (const listener of this.#profileListeners) listener(message.stats);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pending = this.#pending.get(message?.request);
|
const pending = this.#pending.get(message?.request);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
this.#pending.delete(message.request);
|
this.#pending.delete(message.request);
|
||||||
@@ -231,6 +241,7 @@ export class YawnCore {
|
|||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.#fail("DISPOSED");
|
this.#fail("DISPOSED");
|
||||||
|
clearInterval(this.#profileTimer);
|
||||||
this.#profileListeners.clear();
|
this.#profileListeners.clear();
|
||||||
this.#worker.terminate();
|
this.#worker.terminate();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -302,7 +302,8 @@ impl Core {
|
|||||||
supported
|
supported
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn take_profile(&self) -> Option<String> {
|
/// Installs the worker-local sink that publishes completed GPU timings into shared memory.
|
||||||
self.render.take_profile()
|
pub fn on_profile(&self, callback: js_sys::Function) {
|
||||||
|
self.render.on_profile(callback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -4,5 +4,13 @@
|
|||||||
"description": "Rust/WASM render data and render graph core",
|
"description": "Rust/WASM render data and render graph core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./index.js",
|
"exports": "./index.js",
|
||||||
"files": ["index.js", "worker.js", "pkg"]
|
"scripts": {
|
||||||
|
"test": "node --test profile.test.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"worker.js",
|
||||||
|
"profile.js",
|
||||||
|
"pkg"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Single-writer/single-reader latest-sample mailbox. Header: state, UTF-8 byte length.
|
||||||
|
// State 0 = empty, 1 = owned by a reader/writer, 2 = complete sample.
|
||||||
|
// Neither side waits: an unread sample can be replaced, and a busy publication is skipped.
|
||||||
|
export class SharedProfile {
|
||||||
|
constructor(buffer = new SharedArrayBuffer(1024 * 1024)) {
|
||||||
|
this.buffer = buffer;
|
||||||
|
this.header = new Int32Array(buffer, 0, 2);
|
||||||
|
this.bytes = new Uint8Array(buffer, 8);
|
||||||
|
this.encoder = new TextEncoder();
|
||||||
|
this.decoder = new TextDecoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
write(json) {
|
||||||
|
const bytes = this.encoder.encode(json);
|
||||||
|
if (bytes.length > this.bytes.length) return false;
|
||||||
|
const state = Atomics.load(this.header, 0);
|
||||||
|
if (
|
||||||
|
state === 1 ||
|
||||||
|
Atomics.compareExchange(this.header, 0, state, 1) !== state
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
this.bytes.set(bytes);
|
||||||
|
this.header[1] = bytes.length;
|
||||||
|
Atomics.store(this.header, 0, 2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
read() {
|
||||||
|
if (Atomics.compareExchange(this.header, 0, 2, 1) !== 2) return;
|
||||||
|
let bytes;
|
||||||
|
try {
|
||||||
|
bytes = this.bytes.slice(0, this.header[1]);
|
||||||
|
} finally {
|
||||||
|
Atomics.store(this.header, 0, 0);
|
||||||
|
}
|
||||||
|
return JSON.parse(this.decoder.decode(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
Atomics.compareExchange(this.header, 0, 2, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
import { SharedProfile } from "./profile.js";
|
||||||
|
import { YawnCore } from "./index.js";
|
||||||
|
|
||||||
|
test("shared profiles replace unread samples and exclude overlapping reads/writes", () => {
|
||||||
|
const writer = new SharedProfile(new SharedArrayBuffer(512));
|
||||||
|
const reader = new SharedProfile(writer.buffer);
|
||||||
|
assert.equal(reader.read(), undefined);
|
||||||
|
writer.write(JSON.stringify({ frame: 1, name: "GPU → SAB" }));
|
||||||
|
writer.write(JSON.stringify({ frame: 2, name: "light + opaque" }));
|
||||||
|
assert.deepEqual(reader.read(), { frame: 2, name: "light + opaque" });
|
||||||
|
assert.equal(reader.read(), undefined);
|
||||||
|
Atomics.store(writer.header, 0, 1);
|
||||||
|
assert.equal(writer.write('{"frame":3}'), false);
|
||||||
|
assert.equal(reader.read(), undefined);
|
||||||
|
Atomics.store(writer.header, 0, 0);
|
||||||
|
assert.equal(writer.write(" ".repeat(513)), false);
|
||||||
|
assert.equal(writer.write('{"frame":4}'), true);
|
||||||
|
writer.clear();
|
||||||
|
assert.equal(reader.read(), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent shared profile publication never mixes samples", async () => {
|
||||||
|
const reader = new SharedProfile();
|
||||||
|
const done = new Int32Array(new SharedArrayBuffer(4));
|
||||||
|
const worker = new Worker(
|
||||||
|
`
|
||||||
|
const {workerData} = require('node:worker_threads');
|
||||||
|
(async () => {
|
||||||
|
const {SharedProfile} = await import(workerData.module);
|
||||||
|
const writer = new SharedProfile(workerData.buffer);
|
||||||
|
for (let frame = 1; frame <= 2000; frame++) {
|
||||||
|
const json = JSON.stringify({frame, values: Array(128).fill(frame * 3 + 7), name: 'GPU → SAB'});
|
||||||
|
while (!writer.write(json)) await new Promise(r => setImmediate(r));
|
||||||
|
}
|
||||||
|
Atomics.store(new Int32Array(workerData.done), 0, 1);
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
eval: true,
|
||||||
|
workerData: {
|
||||||
|
module: new URL("./profile.js", import.meta.url).href,
|
||||||
|
buffer: reader.buffer,
|
||||||
|
done: done.buffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const exit = new Promise((resolve, reject) => {
|
||||||
|
worker.once("error", reject);
|
||||||
|
worker.once("exit", (code) =>
|
||||||
|
code ? reject(new Error(`Worker exit ${code}`)) : resolve(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
let last = 0;
|
||||||
|
while (!Atomics.load(done, 0) || Atomics.load(reader.header, 0) === 2) {
|
||||||
|
const sample = reader.read();
|
||||||
|
if (sample) {
|
||||||
|
assert.ok(sample.frame > last);
|
||||||
|
assert.deepEqual(sample.values, Array(128).fill(sample.frame * 3 + 7));
|
||||||
|
assert.equal(sample.name, "GPU → SAB");
|
||||||
|
last = sample.frame;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
}
|
||||||
|
await exit;
|
||||||
|
assert.equal(last, 2000);
|
||||||
|
} finally {
|
||||||
|
await worker.terminate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("onProfile reads SAB without result messages and stops on unsubscribe/dispose", async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ["setInterval"] });
|
||||||
|
const messages = [];
|
||||||
|
let receive;
|
||||||
|
let writer;
|
||||||
|
const worker = {
|
||||||
|
addEventListener(type, listener) {
|
||||||
|
if (type === "message") receive = listener;
|
||||||
|
},
|
||||||
|
postMessage(message) {
|
||||||
|
messages.push(message.type);
|
||||||
|
writer = new SharedProfile(message.profileBuffer);
|
||||||
|
queueMicrotask(() =>
|
||||||
|
receive({
|
||||||
|
data: {
|
||||||
|
request: message.request,
|
||||||
|
result: {
|
||||||
|
buffer: new SharedArrayBuffer(64),
|
||||||
|
features: [],
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
terminate() {},
|
||||||
|
};
|
||||||
|
const core = new YawnCore({}, { workerFactory: () => worker });
|
||||||
|
await core.ready;
|
||||||
|
const samples = [];
|
||||||
|
const stop = core.onProfile((sample) => samples.push(sample));
|
||||||
|
writer.write('{"frame":9,"milliseconds":1.25}');
|
||||||
|
t.mock.timers.tick(99);
|
||||||
|
assert.equal(samples.length, 0);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
assert.deepEqual(samples, [{ frame: 9, milliseconds: 1.25 }]);
|
||||||
|
t.mock.timers.tick(100);
|
||||||
|
assert.equal(samples.length, 1);
|
||||||
|
assert.deepEqual(messages, ["init"]);
|
||||||
|
stop();
|
||||||
|
writer.write('{"frame":10}');
|
||||||
|
t.mock.timers.tick(100);
|
||||||
|
assert.equal(samples.length, 1);
|
||||||
|
core.onProfile((sample) => samples.push(sample));
|
||||||
|
core.dispose();
|
||||||
|
t.mock.timers.tick(100);
|
||||||
|
assert.equal(samples.length, 1);
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::num::NonZeroU64;
|
use std::num::NonZeroU64;
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures_channel::oneshot;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ pub struct GpuProfiler {
|
|||||||
|
|
||||||
pub struct ProfileMap {
|
pub struct ProfileMap {
|
||||||
readback: Arc<wgpu::Buffer>,
|
readback: Arc<wgpu::Buffer>,
|
||||||
state: Arc<AtomicU8>,
|
completion: oneshot::Receiver<Result<(), wgpu::BufferAsyncError>>,
|
||||||
labels: Vec<String>,
|
labels: Vec<String>,
|
||||||
timestamp_period: f32,
|
timestamp_period: f32,
|
||||||
}
|
}
|
||||||
@@ -417,16 +417,15 @@ impl GpuResources {
|
|||||||
|
|
||||||
pub fn map_profile(&self, timestamp_period: f32) -> Option<ProfileMap> {
|
pub fn map_profile(&self, timestamp_period: f32) -> Option<ProfileMap> {
|
||||||
let profiler = self.profiler.as_ref()?;
|
let profiler = self.profiler.as_ref()?;
|
||||||
let state = Arc::new(AtomicU8::new(0));
|
let (complete, completion) = oneshot::channel();
|
||||||
let callback = state.clone();
|
|
||||||
profiler
|
profiler
|
||||||
.readback
|
.readback
|
||||||
.map_async(wgpu::MapMode::Read, .., move |result| {
|
.map_async(wgpu::MapMode::Read, .., move |result| {
|
||||||
callback.store(if result.is_ok() { 1 } else { 2 }, Ordering::Release);
|
let _ = complete.send(result);
|
||||||
});
|
});
|
||||||
Some(ProfileMap {
|
Some(ProfileMap {
|
||||||
readback: profiler.readback.clone(),
|
readback: profiler.readback.clone(),
|
||||||
state,
|
completion,
|
||||||
labels: self
|
labels: self
|
||||||
.passes
|
.passes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -438,11 +437,8 @@ impl GpuResources {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileMap {
|
impl ProfileMap {
|
||||||
pub fn state(&self) -> u8 {
|
pub async fn read(self) -> Option<Vec<(String, f64)>> {
|
||||||
self.state.load(Ordering::Acquire)
|
self.completion.await.ok()?.ok()?;
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read(self) -> Vec<(String, f64)> {
|
|
||||||
let bytes = self.readback.get_mapped_range(..);
|
let bytes = self.readback.get_mapped_range(..);
|
||||||
let values = bytes
|
let values = bytes
|
||||||
.chunks_exact(8)
|
.chunks_exact(8)
|
||||||
@@ -463,7 +459,7 @@ impl ProfileMap {
|
|||||||
.collect();
|
.collect();
|
||||||
drop(bytes);
|
drop(bytes);
|
||||||
self.readback.unmap();
|
self.readback.unmap();
|
||||||
profile
|
Some(profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-39
@@ -10,26 +10,13 @@ use crate::render_data::RenderData;
|
|||||||
use crate::store::{Loadout, Store};
|
use crate::store::{Loadout, Store};
|
||||||
|
|
||||||
#[wasm_bindgen(inline_js = r#"
|
#[wasm_bindgen(inline_js = r#"
|
||||||
const channel = new MessageChannel();
|
|
||||||
const ready = [];
|
|
||||||
channel.port1.onmessage = () => ready.shift()?.();
|
|
||||||
|
|
||||||
export function nextFrame() {
|
export function nextFrame() {
|
||||||
return new Promise(resolve => requestAnimationFrame(resolve));
|
return new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function yieldTask() {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
ready.push(resolve);
|
|
||||||
channel.port2.postMessage(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"#)]
|
"#)]
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#[wasm_bindgen(js_name = nextFrame)]
|
#[wasm_bindgen(js_name = nextFrame)]
|
||||||
async fn next_frame();
|
async fn next_frame();
|
||||||
#[wasm_bindgen(js_name = yieldTask)]
|
|
||||||
async fn yield_task();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RenderLoop {
|
pub struct RenderLoop {
|
||||||
@@ -42,7 +29,8 @@ pub struct RenderLoop {
|
|||||||
profiling: Cell<bool>,
|
profiling: Cell<bool>,
|
||||||
profile_pending: Cell<bool>,
|
profile_pending: Cell<bool>,
|
||||||
profile_after: Cell<f64>,
|
profile_after: Cell<f64>,
|
||||||
profile: RefCell<Option<String>>,
|
profile_callback: RefCell<Option<js_sys::Function>>,
|
||||||
|
profile_generation: Cell<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderLoop {
|
impl RenderLoop {
|
||||||
@@ -57,7 +45,8 @@ impl RenderLoop {
|
|||||||
profiling: Cell::new(false),
|
profiling: Cell::new(false),
|
||||||
profile_pending: Cell::new(false),
|
profile_pending: Cell::new(false),
|
||||||
profile_after: Cell::new(0.0),
|
profile_after: Cell::new(0.0),
|
||||||
profile: RefCell::new(None),
|
profile_callback: RefCell::new(None),
|
||||||
|
profile_generation: Cell::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,13 +70,12 @@ impl RenderLoop {
|
|||||||
pub fn set_profiling(&self, enabled: bool) {
|
pub fn set_profiling(&self, enabled: bool) {
|
||||||
self.profiling.set(enabled);
|
self.profiling.set(enabled);
|
||||||
self.profile_after.set(0.0);
|
self.profile_after.set(0.0);
|
||||||
if !enabled {
|
self.profile_generation
|
||||||
self.profile.borrow_mut().take();
|
.set(self.profile_generation.get().wrapping_add(1));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn take_profile(&self) -> Option<String> {
|
pub fn on_profile(&self, callback: js_sys::Function) {
|
||||||
self.profile.borrow_mut().take()
|
*self.profile_callback.borrow_mut() = Some(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(
|
pub fn start(
|
||||||
@@ -152,15 +140,12 @@ impl RenderLoop {
|
|||||||
};
|
};
|
||||||
if let Some((profile, adapter, width, height)) = submission {
|
if let Some((profile, adapter, width, height)) = submission {
|
||||||
control.profile_pending.set(true);
|
control.profile_pending.set(true);
|
||||||
control.profile_after.set(started + 250.0);
|
control.profile_after.set(started + 100.0);
|
||||||
|
let generation = control.profile_generation.get();
|
||||||
let control = control.clone();
|
let control = control.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
while profile.state() == 0 {
|
if let Some(passes) = profile.read().await {
|
||||||
yield_task().await;
|
let passes = passes
|
||||||
}
|
|
||||||
if profile.state() == 1 {
|
|
||||||
let passes = profile
|
|
||||||
.read()
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(name, milliseconds)| {
|
.map(|(name, milliseconds)| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -173,18 +158,22 @@ impl RenderLoop {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|pass| pass["milliseconds"].as_f64())
|
.filter_map(|pass| pass["milliseconds"].as_f64())
|
||||||
.sum::<f64>();
|
.sum::<f64>();
|
||||||
if control.profiling.get() {
|
if control.profiling.get()
|
||||||
*control.profile.borrow_mut() = Some(
|
&& control.profile_generation.get() == generation
|
||||||
serde_json::json!({
|
{
|
||||||
"frame": frame,
|
let sample = serde_json::json!({
|
||||||
"milliseconds": milliseconds,
|
"frame": frame,
|
||||||
"readbackMilliseconds": js_sys::Date::now() - started,
|
"milliseconds": milliseconds,
|
||||||
"adapter": adapter,
|
"readbackMilliseconds": js_sys::Date::now() - started,
|
||||||
"canvas": { "width": width, "height": height },
|
"adapter": adapter,
|
||||||
"passes": passes,
|
"canvas": { "width": width, "height": height },
|
||||||
})
|
"passes": passes,
|
||||||
.to_string(),
|
})
|
||||||
);
|
.to_string();
|
||||||
|
if let Some(callback) = control.profile_callback.borrow().as_ref() {
|
||||||
|
let _ = callback
|
||||||
|
.call1(&wasm_bindgen::JsValue::NULL, &sample.into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
control.profile_pending.set(false);
|
control.profile_pending.set(false);
|
||||||
|
|||||||
+5
-6
@@ -1,7 +1,8 @@
|
|||||||
import initWasm, { Core } from "./pkg/yawn_core.js";
|
import initWasm, { Core } from "./pkg/yawn_core.js";
|
||||||
|
import { SharedProfile } from "./profile.js";
|
||||||
|
|
||||||
let core;
|
let core;
|
||||||
let profileTimer;
|
let profile;
|
||||||
// While a relocation is staged, every request except its commit waits here.
|
// While a relocation is staged, every request except its commit waits here.
|
||||||
let awaitingCommit = false;
|
let awaitingCommit = false;
|
||||||
const queue = [];
|
const queue = [];
|
||||||
@@ -15,6 +16,8 @@ async function handle(message) {
|
|||||||
if (core || !(message.canvas instanceof OffscreenCanvas)) fail("INIT");
|
if (core || !(message.canvas instanceof OffscreenCanvas)) fail("INIT");
|
||||||
const wasm = await initWasm();
|
const wasm = await initWasm();
|
||||||
core = new Core(message.arenaBytes);
|
core = new Core(message.arenaBytes);
|
||||||
|
profile = new SharedProfile(message.profileBuffer);
|
||||||
|
core.on_profile(json => profile.write(json));
|
||||||
await core.initialize(message.canvas);
|
await core.initialize(message.canvas);
|
||||||
const buffer = wasm.memory.buffer;
|
const buffer = wasm.memory.buffer;
|
||||||
if (!(buffer instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED");
|
if (!(buffer instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED");
|
||||||
@@ -70,11 +73,7 @@ async function handle(message) {
|
|||||||
break;
|
break;
|
||||||
case "set-profiler":
|
case "set-profiler":
|
||||||
result = core.set_profiler(Boolean(message.enabled));
|
result = core.set_profiler(Boolean(message.enabled));
|
||||||
clearInterval(profileTimer);
|
profile.clear();
|
||||||
profileTimer = result && message.enabled ? setInterval(() => {
|
|
||||||
const stats = core.take_profile();
|
|
||||||
if (stats) postMessage({ type: "profile", stats: JSON.parse(stats) });
|
|
||||||
}, 250) : undefined;
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
fail("MESSAGE");
|
fail("MESSAGE");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ All fields below are top-level beside `type` and `request`. Except for `init` an
|
|||||||
|
|
||||||
| `type` | Fields | Transfer list | Success `result` | Constraints / effects |
|
| `type` | Fields | Transfer list | Success `result` | Constraints / effects |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `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`). |
|
| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number`, `profileBuffer: SharedArrayBuffer` | `[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`). `profileBuffer` is shared, not transferred. |
|
||||||
| `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` | `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. |
|
| `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, refreshes bound GPU buffers once, sets `sabDirty`. While a relocation is staged the worker queues every other request until this arrives. |
|
||||||
@@ -23,25 +23,26 @@ All fields below are top-level beside `type` and `request`. Except for `init` an
|
|||||||
|
|
||||||
Rust/WASM numeric conversion applies to `u32` fields; callers should send finite nonnegative integers in range rather than rely on coercion.
|
Rust/WASM numeric conversion applies to `u32` fields; callers should send finite nonnegative integers in range rather than rely on coercion.
|
||||||
|
|
||||||
## Profiler events
|
## Shared profiler results
|
||||||
|
|
||||||
When enabled and supported, the worker polls every 250 ms and may send an unsolicited message with **no request field**:
|
When enabled and supported, GPU timestamp queries are sampled at most once every 100 ms, with only one readback in flight. Completion of `map_async` wakes an awaiting future; neither the worker nor render loop polls for GPU completion. Rendering continues independently.
|
||||||
|
|
||||||
|
Completed results are published into `profileBuffer`, not sent as worker messages. `YawnCore.onProfile` reads this buffer locally every 100 ms while listeners exist and delivers the same stats shape:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
type: "profile",
|
|
||||||
stats: {
|
|
||||||
frame: 42,
|
frame: 42,
|
||||||
milliseconds: 0.31,
|
milliseconds: 0.31,
|
||||||
readbackMilliseconds: 4.8,
|
readbackMilliseconds: 4.8,
|
||||||
adapter: "Adapter name · DeviceType · Backend",
|
adapter: "Adapter name · DeviceType · Backend",
|
||||||
canvas: { width: 1280, height: 720 },
|
canvas: { width: 1280, height: 720 },
|
||||||
passes: [{ name: "triangle", milliseconds: 0.31 }]
|
passes: [{ name: "triangle", milliseconds: 0.31 }]
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
One pass timing corresponds to a compiled execution; compatible adjacent render passes are merged into one execution labelled `first … last (N draws)`. `milliseconds` is GPU time; `readbackMilliseconds` includes the asynchronous wait. Disabling clears published statistics and suppresses results from older profiling sessions.
|
||||||
|
|
||||||
|
The default mailbox is a separate 1 MiB SAB. Its first two `Int32` values are ownership state and UTF-8 JSON length; the payload starts at byte 8. State 0 means empty, 1 means owned by a reader or writer, and 2 means a complete sample. Both sides claim ownership using `Atomics.compareExchange` and never wait. An unread sample is replaced by the latest sample; a publication during a read, or larger than the payload capacity, is skipped. The reader copies the bytes before releasing ownership, so it cannot observe a partially written result. Initialization and profiler enable/disable remain ordinary control requests; results never use `postMessage`.
|
||||||
|
|
||||||
## Texture lifecycle
|
## Texture lifecycle
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"server": "go run ./server",
|
"server": "go run ./server",
|
||||||
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
|
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
|
||||||
"build:coredocs": "vitepress build coredocs",
|
"build:coredocs": "vitepress build coredocs",
|
||||||
"test": "npm run test:handles && npm run test:fxnode-rg",
|
"test": "npm test --workspace @yawn/core && npm run test:handles && npm run test:fxnode-rg",
|
||||||
"test:handles": "npm test --workspace @yawn/handles",
|
"test:handles": "npm test --workspace @yawn/handles",
|
||||||
"test:fxnode-rg": "npm test --workspace @yawn/fxnode-rg",
|
"test:fxnode-rg": "npm test --workspace @yawn/fxnode-rg",
|
||||||
"test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles"
|
"test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles"
|
||||||
|
|||||||
+10
-10
@@ -34,12 +34,20 @@ const fxNodeImports = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const coreWorker = await build({
|
||||||
|
...browser,
|
||||||
|
entryPoints: [path.join(root, "core", "worker.js")],
|
||||||
|
external: ["./pkg/yawn_core.js"],
|
||||||
|
write: false,
|
||||||
|
});
|
||||||
|
const coreWorkerSource = coreWorker.outputFiles[0].text;
|
||||||
|
|
||||||
const workerFiles = new Map([
|
const workerFiles = new Map([
|
||||||
[
|
[
|
||||||
path.join(root, "core", "index.js"),
|
path.join(root, "core", "index.js"),
|
||||||
{
|
{
|
||||||
url: "./worker.js",
|
url: "./worker.js",
|
||||||
source: await fs.readFile(path.join(root, "core", "worker.js"), "utf8"),
|
source: coreWorkerSource,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -113,15 +121,7 @@ await Promise.all([
|
|||||||
entryPoints: [path.join(fxNodeSource, "worker", "fxnode.worker.ts")],
|
entryPoints: [path.join(fxNodeSource, "worker", "fxnode.worker.ts")],
|
||||||
outfile: path.join(output, "fxnode-worker.js"),
|
outfile: path.join(output, "fxnode-worker.js"),
|
||||||
}),
|
}),
|
||||||
build({
|
fs.writeFile(path.join(output, "worker.js"), coreWorkerSource),
|
||||||
bundle: false,
|
|
||||||
entryPoints: [path.join(root, "core", "worker.js")],
|
|
||||||
format: "esm",
|
|
||||||
legalComments: "none",
|
|
||||||
minify: true,
|
|
||||||
outfile: path.join(output, "worker.js"),
|
|
||||||
target: "es2022",
|
|
||||||
}),
|
|
||||||
build({
|
build({
|
||||||
...browser,
|
...browser,
|
||||||
entryPoints: [
|
entryPoints: [
|
||||||
|
|||||||
+15
-10
@@ -224,6 +224,7 @@ async function openFile(file) {
|
|||||||
scene = new Scene(canvas, {
|
scene = new Scene(canvas, {
|
||||||
arenaBytes: 384 * 1024 * 1024,
|
arenaBytes: 384 * 1024 * 1024,
|
||||||
hdr: true,
|
hdr: true,
|
||||||
|
directToCanvas: true,
|
||||||
clearColor: [0.025, 0.035, 0.045, 1],
|
clearColor: [0.025, 0.035, 0.045, 1],
|
||||||
});
|
});
|
||||||
await scene.ready;
|
await scene.ready;
|
||||||
@@ -266,16 +267,20 @@ async function openFile(file) {
|
|||||||
);
|
);
|
||||||
await Promise.all([...lights.map((light) => light.ready), camera.ready]);
|
await Promise.all([...lights.map((light) => light.ready), camera.ready]);
|
||||||
|
|
||||||
elements.renderValue.textContent = "— ms";
|
// Diagnostic A/B: omit queries and readback, not just the timing badge.
|
||||||
elements.renderTime.hidden = false;
|
let stopProfile;
|
||||||
const stopProfile = scene.core.onProfile((profile) => {
|
if (new URLSearchParams(location.search).get("profile") !== "off") {
|
||||||
elements.renderValue.textContent = `${profile.milliseconds.toFixed(2)} ms`;
|
elements.renderValue.textContent = "— ms";
|
||||||
});
|
elements.renderTime.hidden = false;
|
||||||
const profiling = await scene.core.setProfiler(true);
|
stopProfile = scene.core.onProfile((profile) => {
|
||||||
if (!profiling)
|
elements.renderValue.textContent = `${profile.milliseconds.toFixed(2)} ms`;
|
||||||
elements.renderTime.title =
|
});
|
||||||
"GPU timestamp queries are not supported by this adapter.";
|
const profiling = await scene.core.setProfiler(true);
|
||||||
else elements.renderTime.removeAttribute("title");
|
if (!profiling)
|
||||||
|
elements.renderTime.title =
|
||||||
|
"GPU timestamp queries are not supported by this adapter.";
|
||||||
|
else elements.renderTime.removeAttribute("title");
|
||||||
|
}
|
||||||
|
|
||||||
activeViewer = {
|
activeViewer = {
|
||||||
camera,
|
camera,
|
||||||
|
|||||||
Reference in New Issue
Block a user