diff --git a/.agents/setup b/.agents/setup index 409be7d..3e234f0 100755 --- a/.agents/setup +++ b/.agents/setup @@ -18,7 +18,12 @@ if ! command -v git-lfs >/dev/null; then sudo apt-get update -qq sudo apt-get install -y git-lfs fi +if ! command -v Xvfb >/dev/null || ! command -v magick >/dev/null || ! command -v xauth >/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y imagemagick libvulkan1 mesa-vulkan-drivers xauth xvfb +fi git lfs install --local git lfs pull --include="docs/public/models/sponza.glb" npm ci +npx agent-browser install amp orb services ensure diff --git a/.gitignore b/.gitignore index 79ea7f8..baca8d5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ docs/.vitepress/cache/ docs/.vitepress/dist/ server/data/ yawn-data/ +addons/handles/test/screenshots/output/ # Amp runtime artifacts .amp/in/ diff --git a/addons/handles/package.json b/addons/handles/package.json index 70c6dc9..a1a65d5 100644 --- a/addons/handles/package.json +++ b/addons/handles/package.json @@ -4,6 +4,10 @@ "description": "Conventional scene handles over Yawn's worker and shared render data", "type": "module", "exports": "./src/index.ts", + "scripts": { + "test": "node test/run.mjs", + "test:screenshots": "node --test --test-concurrency=1 test/screenshots/run.mjs" + }, "dependencies": { "@yawn/core": "0.1.0" } diff --git a/addons/handles/src/Mesh.ts b/addons/handles/src/Mesh.ts index e938ca1..2294a46 100644 --- a/addons/handles/src/Mesh.ts +++ b/addons/handles/src/Mesh.ts @@ -2,7 +2,15 @@ import { Node, type NodeOptions } from "./Node"; import type { Scene } from "./Scene"; import type { PBRMaterial } from "./materials/PBRMaterial"; -export const VertexKinds = Object.freeze(["positions", "normals", "tangents", "uvs", "colors", "indices"] as const); +export const VertexKinds = Object.freeze([ + "positions", + "normals", + "tangents", + "uvs", + "uvs2", + "colors", + "indices", +] as const); export type VertexKind = (typeof VertexKinds)[number]; export type MeshOptions = NodeOptions & { geometryId?: number; @@ -27,15 +35,22 @@ export class Mesh extends Node { this.ready = nodeReady.then(async () => { if (options.material) await options.material.ready; await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32"); - this.vertexCount = Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) / 3; - this.indexCount = Number(scene.geometryData(this.geometryId, "indices")?.length ?? 0); - this.instanceOf = options.geometryId === undefined ? this.id : this.geometryId; - scene.array("meshInfo").write(this.id, [ - this.geometryId, - options.material?.id ?? 0, - options.visible === false ? 0 : 1, - this.instanceOf, - ]); + this.vertexCount = + Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) / + 3; + this.indexCount = Number( + scene.geometryData(this.geometryId, "indices")?.length ?? 0, + ); + this.instanceOf = + options.geometryId === undefined ? this.id : this.geometryId; + scene + .array("meshInfo") + .write(this.id, [ + this.geometryId, + options.material?.id ?? 0, + options.visible === false ? 0 : 1, + this.instanceOf, + ]); for (const [kind, data] of Object.entries(options.vertexData ?? {})) await this.#setVertexData(kind as VertexKind, data!, false); this.#writeBounds(); @@ -60,7 +75,8 @@ export class Mesh extends Node { } set material(value: PBRMaterial) { - if (value.scene !== this.scene || value.id < 0) throw new Error("Await a material from the same Scene"); + if (value.scene !== this.scene || value.id < 0) + throw new Error("Await a material from the same Scene"); this.scene.markDirty(true); this.scene.array("meshInfo").row(this.id)[1] = value.id; void this.scene.updateRenderGraph().catch(() => undefined); @@ -72,13 +88,18 @@ export class Mesh extends Node { } async setVertexData(kind: VertexKind, data: ArrayLike) { - if (!VertexKinds.includes(kind)) throw new TypeError(`Unknown vertex kind: ${kind}`); + if (!VertexKinds.includes(kind)) + throw new TypeError(`Unknown vertex kind: ${kind}`); await this.ready; await this.#setVertexData(kind, data, true); return this; } - async #setVertexData(kind: VertexKind, data: ArrayLike, makeUnique: boolean) { + async #setVertexData( + kind: VertexKind, + data: ArrayLike, + makeUnique: boolean, + ) { if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) { const original = this.geometryId; this.geometryId = await this.scene.cloneGeometry(original); @@ -86,7 +107,15 @@ export class Mesh extends Node { this.scene.referenceGeometry(this.geometryId); this.instanceOf = this.id; this.scene.markDirty(true); - this.scene.array("meshInfo").row(this.id).set([this.geometryId, this.materialId, this.isVisible ? 1 : 0, this.id]); + this.scene + .array("meshInfo") + .row(this.id) + .set([ + this.geometryId, + this.materialId, + this.isVisible ? 1 : 0, + this.id, + ]); } if (kind === "positions") this.vertexCount = data.length / 3; if (kind === "indices") this.indexCount = data.length; @@ -96,10 +125,16 @@ export class Mesh extends Node { async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) { await Promise.all([this.ready, material.ready]); - if (material.scene !== this.scene) throw new Error("Material must belong to the same Scene"); + if (material.scene !== this.scene) + throw new Error("Material must belong to the same Scene"); const list = Array.isArray(faces) ? faces : [faces]; const maximum = Math.max(...list); - const array = await this.scene.ensureRows(`mesh.${this.id}.faceMaterials`, maximum + 1, 16, "u32"); + const array = await this.scene.ensureRows( + `mesh.${this.id}.faceMaterials`, + maximum + 1, + 16, + "u32", + ); this.scene.markDirty(true); for (const face of list) { if (!Number.isInteger(face) || face < 0) throw new RangeError("face"); @@ -120,7 +155,10 @@ export class Mesh extends Node { minimum[lane] = Math.min(minimum[lane], positions[index + lane]); maximum[lane] = Math.max(maximum[lane], positions[index + lane]); } - this.scene.array("bounds").row(this.id).set([...minimum, 0, ...maximum, 0]); + this.scene + .array("bounds") + .row(this.id) + .set([...minimum, 0, ...maximum, 0]); } override async dispose() { diff --git a/addons/handles/src/Scene.ts b/addons/handles/src/Scene.ts index 90e46a6..fa5650c 100644 --- a/addons/handles/src/Scene.ts +++ b/addons/handles/src/Scene.ts @@ -28,6 +28,7 @@ type PostProcessState = { type TextureState = GraphTexture & { number: number; source?: string | ImageBitmap; + sampler?: Omit; }; const rows = [ @@ -40,7 +41,9 @@ const rows = [ ["cameras", 80, "f32"], ["cameraMatrices", 80, "f32"], ["materials", 48, "f32"], - ["materialTextures", 32, "u32"], + ["materialTextures", 64, "u32"], + ["materialExtensions", 112, "f32"], + ["materialTextureInfo", 416, "f32"], ["pointLights", 32, "f32"], ["rectAreaLights", 48, "f32"], ["spotLights", 32, "f32"], @@ -49,6 +52,11 @@ const rows = [ ["sceneAccent", 16, "f32"], ] as const; +const compactLightLimit = 500; +const compactPointStart = 11; +const compactSpotCount = compactPointStart + compactLightLimit; +const compactSpotStart = compactSpotCount + 1; + const clusterShader = /* wgsl */ ` @group(0) @binding(0) var pointLights: array>; @group(0) @binding(1) var rectLights: array>; @@ -61,12 +69,20 @@ const clusterShader = /* wgsl */ ` fn main(@builtin(global_invocation_id) id: vec3) { if (id.x != 0u) { return; } var count = 0u; + var pointCount = 0u; + var spotCount = 0u; var light = vec3(0.0); var pointLight = vec3(0.0); var spotLight = vec3(0.0); for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) { let enabled = pointLights[i * 2u + 1u].y; count += select(0u, 1u, enabled != 0.0); + if (enabled != 0.0) { + if (pointCount < ${compactLightLimit}u) { + clusters[${compactPointStart}u + pointCount] = i; + } + pointCount += 1u; + } let contribution = pointLights[i * 2u].rgb * pointLights[i * 2u].a * enabled * 0.02; light += contribution; pointLight += contribution; @@ -79,6 +95,12 @@ fn main(@builtin(global_invocation_id) id: vec3) { for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) { let enabled = spotLights[i * 2u + 1u].w; count += select(0u, 1u, enabled != 0.0); + if (enabled != 0.0) { + if (spotCount < ${compactLightLimit}u) { + clusters[${compactSpotStart}u + spotCount] = i; + } + spotCount += 1u; + } let contribution = spotLights[i * 2u].rgb * spotLights[i * 2u].a * enabled * 0.02; light += contribution; spotLight += contribution; @@ -102,6 +124,8 @@ fn main(@builtin(global_invocation_id) id: vec3) { clusters[7] = bitcast(spotLight.r); clusters[8] = bitcast(spotLight.g); clusters[9] = bitcast(spotLight.b); + clusters[10] = pointCount; + clusters[${compactSpotCount}] = spotCount; }`; const basicForwardShader = /* wgsl */ ` @@ -162,10 +186,70 @@ fn fragment(input: VertexOutput) -> @location(0) vec4 { return vec4(color, base.a); }`; -function pbrShader(mask: number) { - const baseTexture = mask & 1; - const materialTexture = mask & 2; - const normalTexture = mask & 4; +function pbrShader( + mask: number, + flags: number, + hasColors: boolean, + hasUvs2: boolean, + transmission: boolean, + textureInfo: ArrayLike, + extensionInfo: ArrayLike, +) { + const names = [ + "baseTexture", + "materialTexture", + "normalTexture", + "emissiveTexture", + "occlusionTexture", + "transmissionTexture", + "thicknessTexture", + "clearcoatTexture", + "iridescenceTexture", + "iridescenceThicknessTexture", + "anisotropyTexture", + "specularTexture", + "specularColorTexture", + ]; + const texture = (slot: number) => mask & (1 << slot); + const number = (value: number) => + Number.isFinite(value) + ? Number.isInteger(value) + ? `${value}.0` + : `${value}` + : "0.0"; + const textureUv = (slot: number) => { + const at = slot * 8; + const source = textureInfo[at + 6] > 0.5 ? "input.uv2" : "input.uv"; + if ( + textureInfo[at] === 0 && + textureInfo[at + 1] === 0 && + textureInfo[at + 2] === 1 && + textureInfo[at + 3] === 1 && + textureInfo[at + 4] === 0 && + textureInfo[at + 5] === 1 + ) + return source; + const x = `(${source}.x * ${number(textureInfo[at + 2])})`; + const y = `(${source}.y * ${number(textureInfo[at + 3])})`; + return `vec2(${number(textureInfo[at + 5])} * ${x} - ${number(textureInfo[at + 4])} * ${y}, ${number(textureInfo[at + 4])} * ${x} + ${number(textureInfo[at + 5])} * ${y}) + vec2(${number(textureInfo[at])}, ${number(textureInfo[at + 1])})`; + }; + const sample = (slot: number, fallback = "vec4(1.0)") => + texture(slot) + ? `textureSample(${names[slot]}, materialSampler, ${textureUv(slot)})` + : fallback; + const normalTexture = texture(2); + const alphaMode = flags & 3; + const doubleSided = flags & 4; + const unlit = flags & 8; + const specularGlossiness = flags & 16; + const clearcoat = extensionInfo[8] > 0; + const sheen = + extensionInfo[12] > 0 || extensionInfo[13] > 0 || extensionInfo[14] > 0; + const iridescence = extensionInfo[16] > 0; + const anisotropy = extensionInfo[20] > 0; + const attenuation = transmission && extensionInfo[3] > 0; + const ior = Math.max(extensionInfo[1], 1); + const dielectric = ((ior - 1) / (ior + 1)) ** 2; return /* wgsl */ ` struct VertexOutput { @invariant @builtin(position) position: vec4, @@ -173,8 +257,9 @@ struct VertexOutput { @location(1) normal: vec3, @location(2) uv: vec2, @location(3) tangent: vec4, - @location(4) @interpolate(flat) mesh: u32, - @location(5) @interpolate(flat) material: u32, + @location(4) color: vec4, + @location(5) uv2: vec2, + @location(6) @interpolate(flat) material: u32, } @group(0) @binding(0) var clusters: array; @@ -188,9 +273,8 @@ struct VertexOutput { @group(0) @binding(8) var pointLights: array>; @group(0) @binding(9) var spotLights: array>; ${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""} -${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d;" : ""} -${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d;" : ""} -${normalTexture ? "@group(1) @binding(3) var normalTexture: texture_2d;" : ""} +${names.map((name, slot) => (texture(slot) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d;` : "")).join("\n")} +${transmission ? "@group(2) @binding(0) var sceneColor: texture_2d;\n@group(2) @binding(1) var sceneSampler: sampler;" : ""} fn rotate(rotor: vec4, value: vec3) -> vec3 { return value + 2.0 * cross(rotor.xyz, cross(rotor.xyz, value) + rotor.w * value); @@ -202,6 +286,8 @@ fn vertex( @location(1) localNormal: vec3, @location(2) uv: vec2, ${normalTexture ? "@location(3) localTangent: vec4," : ""} + ${hasColors ? "@location(4) color: vec4," : ""} + ${hasUvs2 ? "@location(5) uv2: vec2," : ""} @builtin(instance_index) packed: u32, ) -> VertexOutput { let instance = packed & 65535u; @@ -223,79 +309,184 @@ fn vertex( output.normal = normalize(rotate(rotors[instance], localNormal / scale)); output.uv = uv; output.tangent = ${normalTexture ? "vec4(normalize(rotate(rotors[instance], localTangent.xyz * scale)), localTangent.w)" : "vec4(1.0, 0.0, 0.0, 1.0)"}; - output.mesh = instance; - output.material = packed >> 16u; + output.color = ${hasColors ? "color" : "vec4(1.0)"}; + output.uv2 = ${hasUvs2 ? "uv2" : "uv"}; + output.material = (packed >> 16u) - 1u; return output; } +fn studioEnvironment(direction: vec3) -> vec3 { + let ray = direction; + let horizon = mix(vec3(0.14, 0.15, 0.16), vec3(0.62, 0.59, 0.54), clamp(ray.y * 0.5 + 0.5, 0.0, 1.0)); + let key = max(dot(ray, normalize(vec3(-0.65, 0.7, 0.35))), 0.0); + let key2 = key * key; + let key4 = key2 * key2; + let key8 = key4 * key4; + let key16 = key8 * key8; + let key32 = key16 * key16; + let key64 = key32 * key32; + return horizon + vec3(5.0, 4.2, 3.1) * key64 * key32; +} + +fn pow5(value: f32) -> f32 { + let squared = value * value; + return squared * squared * value; +} + +fn pointContribution(index: u32, normal: vec3, world: vec3) -> vec3 { + let colorIntensity = pointLights[index * 2u]; + let state = pointLights[index * 2u + 1u]; + if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); } + let offset = positions[index].xyz - world; + let distance = length(offset); + let falloff = max(1.0 - distance / state.x, 0.0); + let incidence = max(dot(normal, offset / max(distance, 0.0001)), 0.0); + return colorIntensity.rgb * colorIntensity.a * falloff * falloff * incidence * 0.08; +} + +fn spotContribution(index: u32, normal: vec3, world: vec3) -> vec3 { + let colorIntensity = spotLights[index * 2u]; + let state = spotLights[index * 2u + 1u]; + if (state.w == 0.0 || state.x <= 0.0) { return vec3(0.0); } + let fromLight = world - positions[index].xyz; + let distance = length(fromLight); + let direction = normalize(rotate(rotors[index], vec3(0.0, 0.0, -1.0))); + 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 incidence = max(dot(normal, -fromLight / max(distance, 0.0001)), 0.0); + return colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * incidence * 0.08; +} + @fragment -fn fragment(input: VertexOutput) -> @location(0) vec4 { - let fallback = meshInfo[input.mesh * 4u + 1u]; - let material = select(fallback, input.material - 1u, input.material != 0u); - let factor = materials[material * 3u]; - let properties = materials[material * 3u + 1u]; - let extra = materials[material * 3u + 2u]; - let base = factor * ${baseTexture ? "textureSample(baseTexture, materialSampler, input.uv)" : "vec4(1.0)"}; - let packedMaterial = ${materialTexture ? "textureSample(materialTexture, materialSampler, input.uv)" : "vec4(1.0)"}; - let metallic = clamp(properties.x * ${materialTexture ? "packedMaterial.b" : "1.0"}, 0.0, 1.0); - let roughness = clamp(properties.y * ${materialTexture ? "packedMaterial.g" : "1.0"}, 0.04, 1.0); +fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4 { + let factor = materials[input.material * 3u]; + let properties = materials[input.material * 3u + 1u]; + let extra = materials[input.material * 3u + 2u]; + let extension0 = vec4(${Array.from(extensionInfo).slice(0, 4).map(number).join(", ")}); + let extension1 = vec4(${Array.from(extensionInfo).slice(4, 8).map(number).join(", ")}); + let extension2 = vec4(${Array.from(extensionInfo).slice(8, 12).map(number).join(", ")}); + let extension3 = vec4(${Array.from(extensionInfo).slice(12, 16).map(number).join(", ")}); + let extension4 = vec4(${Array.from(extensionInfo).slice(16, 20).map(number).join(", ")}); + let extension5 = vec4(${Array.from(extensionInfo).slice(20, 24).map(number).join(", ")}); + let extension6 = vec4(${Array.from(extensionInfo).slice(24, 28).map(number).join(", ")}); + let base = factor * input.color * ${sample(0)}; + ${alphaMode === 1 ? "if (base.a < extra.z) { discard; }" : ""} + let packedMaterial = ${sample(1)}; + let metallic = clamp(properties.x * ${specularGlossiness ? "0.0" : texture(1) ? "packedMaterial.b" : "1.0"}, 0.0, 1.0); + ${anisotropy ? `let anisotropyAmount = clamp(extension5.x * ${sample(10)}.b, 0.0, 1.0);` : ""} + let roughness = clamp(${specularGlossiness ? `1.0 - (1.0 - properties.y) * ${texture(1) ? "packedMaterial.a" : "1.0"}` : `properties.y * ${texture(1) ? "packedMaterial.g" : "1.0"}`}${anisotropy ? " * mix(1.0, 0.7, anisotropyAmount)" : ""}, 0.04, 1.0); var normal = normalize(input.normal); - ${normalTexture ? "let tangent = normalize(input.tangent.xyz - normal * dot(input.tangent.xyz, normal)); let bitangent = cross(normal, tangent) * input.tangent.w; let mapped = textureSample(normalTexture, materialSampler, input.uv).xyz * 2.0 - 1.0; normal = normalize(mat3x3(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));" : ""} + ${doubleSided ? "normal *= select(-1.0, 1.0, frontFacing);" : ""} + ${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(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));` : ""} let view = normalize(cameraMatrices[4].xyz - input.world); let lightDirection = normalize(vec3(0.4, 0.7, 0.6)); let halfVector = normalize(lightDirection + view); let nDotL = max(dot(normal, lightDirection), 0.0); + let nDotV = max(dot(normal, view), 0.0001); let nDotH = max(dot(normal, halfVector), 0.0); - let f0 = mix(vec3(0.04), base.rgb, metallic); - let specular = f0 * pow(nDotH, max(2.0, 2.0 / (roughness * roughness) - 2.0)); + let vDotH = max(dot(view, halfVector), 0.0); + 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)"}; + ${ + iridescence + ? `let iridescenceAmount = clamp(extension4.x * ${sample(8)}.r, 0.0, 1.0); + let iridescenceThickness = mix(extension4.z, extension4.w, ${sample(9)}.g); + let iridescencePhase = iridescenceThickness * 0.025 + (1.0 - nDotV) * 6.0; + let iridescenceTint = 0.12 + 0.88 * (vec3(0.5) + vec3(0.5) * cos(vec3(iridescencePhase, iridescencePhase + 2.0944, iridescencePhase + 4.1888))); + let iridescenceLevel = max(max(f0.r, f0.g), max(f0.b, 0.04)); + f0 = mix(f0, iridescenceTint * iridescenceLevel, iridescenceAmount * (1.0 - roughness * 0.5));` + : "" + } + let fresnelPower = pow5(1.0 - vDotH); + let fresnel = f0 + (vec3(1.0) - f0) * fresnelPower; + let alpha = roughness * roughness; + let alpha2 = alpha * alpha; + let denominator = nDotH * nDotH * (alpha2 - 1.0) + 1.0; + let distribution = alpha2 / max(3.14159265 * denominator * denominator, 0.0001); + 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 clusterLight = vec3(bitcast(clusters[1]), bitcast(clusters[2]), bitcast(clusters[3])); let pointAmbient = vec3(bitcast(clusters[4]), bitcast(clusters[5]), bitcast(clusters[6])); let spotAmbient = vec3(bitcast(clusters[7]), bitcast(clusters[8]), bitcast(clusters[9])); - let ambient = vec3(0.08) + max(clusterLight - pointAmbient - spotAmbient, vec3(0.0)); + let ambient = vec3(0.14) + max(clusterLight - pointAmbient - spotAmbient, vec3(0.0)); var pointDiffuse = vec3(0.0); - for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) { - let colorIntensity = pointLights[i * 2u]; - let state = pointLights[i * 2u + 1u]; - if (state.y == 0.0 || state.x <= 0.0) { continue; } - let offset = positions[i].xyz - input.world; - let distance = length(offset); - let attenuation = pow(max(1.0 - distance / state.x, 0.0), 2.0); - let incidence = max(dot(normal, offset / max(distance, 0.0001)), 0.0); - pointDiffuse += colorIntensity.rgb * colorIntensity.a * attenuation * incidence * 0.08; + let pointCount = clusters[10]; + if (pointCount <= ${compactLightLimit}u) { + for (var light = 0u; light < pointCount; light++) { + pointDiffuse += pointContribution(clusters[${compactPointStart}u + light], normal, input.world); + } + } else { + for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) { + pointDiffuse += pointContribution(light, normal, input.world); + } } var spotDiffuse = vec3(0.0); - for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) { - let colorIntensity = spotLights[i * 2u]; - let state = spotLights[i * 2u + 1u]; - if (state.w == 0.0 || state.x <= 0.0) { continue; } - let fromLight = input.world - positions[i].xyz; - let distance = length(fromLight); - let lightDirection = normalize(rotate(rotors[i], vec3(0.0, 0.0, -1.0))); - let cone = smoothstep(cos(state.z), cos(state.y), dot(lightDirection, fromLight / max(distance, 0.0001))); - let attenuation = pow(max(1.0 - distance / state.x, 0.0), 2.0); - let incidence = max(dot(normal, -fromLight / max(distance, 0.0001)), 0.0); - spotDiffuse += colorIntensity.rgb * colorIntensity.a * cone * attenuation * incidence * 0.08; + let spotCount = clusters[${compactSpotCount}]; + if (spotCount <= ${compactLightLimit}u) { + for (var light = 0u; light < spotCount; light++) { + spotDiffuse += spotContribution(clusters[${compactSpotStart}u + light], normal, input.world); + } + } else { + for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) { + spotDiffuse += spotContribution(light, normal, input.world); + } } let defaultLight = select(1.0, 0.18, clusters[0] != 0u); - let diffuse = base.rgb * (1.0 - metallic) * nDotL * defaultLight; - let emissive = vec3(properties.z, properties.w, extra.x); + let occlusion = mix(1.0, ${sample(4)}.r, extension2.w); + let diffuseColor = base.rgb * (vec3(1.0) - fresnel) * (1.0 - metallic) / 3.14159265; + let diffuse = (diffuseColor + specular) * nDotL * defaultLight; + let reflected = studioEnvironment(reflect(-view, normal)); + let environmentFresnel = f0 + (vec3(1.0) - f0) * pow5(1.0 - nDotV); + let environment = (base.rgb * (1.0 - metallic) * studioEnvironment(normal) * 0.5 + reflected * environmentFresnel * (1.0 - roughness * 0.75)) * occlusion; + let emissive = vec3(properties.z, properties.w, extra.x) * ${sample(3, "vec4(1.0)")}.rgb * extension1.w; let localDiffuse = base.rgb * (1.0 - metallic) * (pointDiffuse + spotDiffuse); - return vec4((base.rgb * ambient + diffuse + localDiffuse + specular * nDotL * defaultLight + emissive) * accent.a, base.a); + ${sheen ? "let sheen = extension3.rgb * pow(1.0 - nDotV, mix(2.0, 8.0, extension3.w));" : ""} + ${ + clearcoat + ? `let clearcoatFresnel = 0.04 + 0.96 * fresnelPower; + let clearcoat = extension2.x * ${sample(7)}.r * clearcoatFresnel * pow(nDotH, mix(256.0, 4.0, extension2.y));` + : "" + } + var color = base.rgb * ambient * occlusion + diffuse + environment + localDiffuse${sheen ? " + sheen" : ""}${clearcoat ? " + vec3(clearcoat)" : ""} + emissive; + ${unlit ? "color = base.rgb + emissive;" : ""} + ${ + transmission + ? `let transmissionAmount = clamp(extension0.x * ${sample(5)}.r, 0.0, 1.0); + let thickness = max(extension0.z * ${sample(6)}.g, 0.0); + let screenUv = input.position.xy / vec2(textureDimensions(sceneColor)); + let refractionOffset = normal.xy * ${number(1 - 1 / ior)} * mix(0.006, 0.035, clamp(thickness, 0.0, 1.0)); + let refractedUv = clamp(screenUv + refractionOffset, vec2(0.001), vec2(0.999)); + let transmissionBlur = vec2(0.02078461 * roughness); + var transmitted = textureSampleLevel(sceneColor, sceneSampler, refractedUv, 0.0).rgb * 0.2; + transmitted += textureSampleLevel(sceneColor, sceneSampler, clamp(refractedUv + transmissionBlur, vec2(0.001), vec2(0.999)), 0.0).rgb * 0.2; + transmitted += textureSampleLevel(sceneColor, sceneSampler, clamp(refractedUv - transmissionBlur, vec2(0.001), vec2(0.999)), 0.0).rgb * 0.2; + transmitted += textureSampleLevel(sceneColor, sceneSampler, clamp(refractedUv + vec2(transmissionBlur.x, -transmissionBlur.y), vec2(0.001), vec2(0.999)), 0.0).rgb * 0.2; + transmitted += textureSampleLevel(sceneColor, sceneSampler, clamp(refractedUv + vec2(-transmissionBlur.x, transmissionBlur.y), vec2(0.001), vec2(0.999)), 0.0).rgb * 0.2; + ${ + attenuation + ? `{ + let travel = thickness / max(abs(dot(normal, view)), 0.15); + transmitted *= pow(clamp(extension1.rgb, vec3(0.001), vec3(1.0)), vec3(travel / extension0.w)); + }` + : "" + } + let normalizedTransmissionTint = base.rgb / max(max(base.r, base.g), max(base.b, 0.001)); + transmitted *= normalizedTransmissionTint; + color = mix(color, transmitted + reflected * environmentFresnel, transmissionAmount);` + : "" + } + return vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"}); }`; } -const emptyForwardShader = /* wgsl */ ` -struct Accent { color: vec4 } -@group(0) @binding(0) var accent: Accent; -struct VertexOutput { @builtin(position) position: vec4 } -@vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput { - let points = array(vec2(-0.72, -0.6), vec2(0.72, -0.6), vec2(0.0, 0.72)); - var output: VertexOutput; - output.position = vec4(points[index], 0.0, 1.0); - return output; +const clearShader = /* wgsl */ ` +@vertex fn vertex() -> @builtin(position) vec4 { + return vec4(0.0); } @fragment fn fragment() -> @location(0) vec4 { - return vec4(accent.color.rgb, 1.0); + return vec4(0.0); }`; const fullscreenVertex = /* wgsl */ ` @@ -311,6 +502,28 @@ struct VertexOutput { return output; }`; +const copyShader = `${fullscreenVertex} +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var sourceSampler: sampler; +@fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { + return textureSample(source, sourceSampler, input.uv); +}`; + +const studioBackground = /* wgsl */ ` +fn studioBackground(uv: vec2) -> vec3 { + let upper = vec3(0.42, 0.43, 0.44); + let lower = vec3(0.16, 0.17, 0.18); + let vertical = smoothstep(0.0, 1.0, uv.y); + let keyBase = max(1.0 - distance(uv, vec2(0.05, 0.05)) / 0.72, 0.0); + return mix(upper, lower, vertical) + vec3(4.0, 3.8, 3.45) * keyBase * keyBase; +}`; + +const studioBackgroundShader = `${fullscreenVertex} +${studioBackground} +@fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { + return vec4(studioBackground(input.uv), 1.0); +}`; + function effectFragment(kind: string, options: Record) { const amount = Number(options.amount ?? options.exposure ?? 1); const safeAmount = Number.isFinite(amount) ? amount : 1; @@ -330,7 +543,10 @@ function effectFragment(kind: string, options: Record) { }`; } -function presentShader(toneMap: string) { +function presentShader( + toneMap: string, + composite: "studio" | "scene" | undefined, +) { const tone = toneMap === "reinhard" ? "color / (color + vec3(1.0))" @@ -338,12 +554,14 @@ function presentShader(toneMap: string) { ? "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} +${composite === "studio" ? studioBackground : ""} @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var sourceSampler: sampler; +${composite === "scene" ? "@group(0) @binding(2) var scene: texture_2d;" : ""} @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { let value = textureSample(source, sourceSampler, input.uv); - let color = max(value.rgb, vec3(0.0)); - return vec4(${tone}, value.a); + let color = max(${composite === "studio" ? "value.rgb + studioBackground(input.uv) * (1.0 - value.a)" : 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"}); }`; } @@ -402,7 +620,12 @@ export class Scene { constructor( canvas: HTMLCanvasElement, - options: { arenaBytes?: number; debug?: boolean; fps?: number; hdr?: boolean } = {}, + options: { + arenaBytes?: number; + debug?: boolean; + fps?: number; + hdr?: boolean; + } = {}, ) { this.hdr = options.hdr ?? true; this.core = new YawnCore(canvas, { @@ -429,7 +652,22 @@ export class Scene { const material = await this.core.allocateObject("materials"); this.core .array("materials") - .write(material, [1, 1, 1, 1, 0, 0.7, 0, 0, 0, 0, 1, 0.5]); + .write(material, [1, 1, 1, 1, 0, 0.7, 0, 0, 0, 1, 0.5, 0]); + this.core + .array("materialExtensions") + .write( + material, + [ + 0, 1.5, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1.3, 100, 400, 0, + 0, 0, 0, 1, 1, 1, 0, + ], + ); + const textureInfo = this.core.array("materialTextureInfo").row(material); + for (let slot = 0; slot < 13; slot++) { + textureInfo[slot * 8 + 2] = 1; + textureInfo[slot * 8 + 3] = 1; + textureInfo[slot * 8 + 5] = 1; + } if (fps !== undefined) await this.core.setFps(fps); await this.#compileRenderGraph(); return this; @@ -471,7 +709,8 @@ export class Scene { const value = Reflect.get(target, property, target); if (typeof value !== "function") return value; if (property === "constructor") return value; - if (!typedArrayMutators.has(String(property))) return value.bind(target); + if (!typedArrayMutators.has(String(property))) + return value.bind(target); return (...arguments_: unknown[]) => { const result = Reflect.apply(value, target, arguments_); this.markDirty(); @@ -570,9 +809,21 @@ export class Scene { { name: "materialTextures", rows: materialCapacity, - stride: 32, + stride: 64, format: "u32" as const, }, + { + name: "materialExtensions", + rows: materialCapacity, + stride: 112, + format: "f32" as const, + }, + { + name: "materialTextureInfo", + rows: materialCapacity, + stride: 416, + format: "f32" as const, + }, ].filter((request) => this.array(request.name).rows < request.rows); if (growth.length) await this.core.createRowsBatch(growth); } @@ -623,7 +874,11 @@ export class Scene { async allocateMaterial() { await this.ready; const id = await this.core.allocateObject("materials"); - await this.ensureRows("materialTextures", id + 1, 32, "u32"); + await Promise.all([ + this.ensureRows("materialTextures", id + 1, 64, "u32"), + this.ensureRows("materialExtensions", id + 1, 112, "f32"), + this.ensureRows("materialTextureInfo", id + 1, 416, "f32"), + ]); return id; } @@ -713,6 +968,7 @@ export class Scene { normals: 3, tangents: 4, uvs: 2, + uvs2: 2, colors: 4, indices: 1, }; @@ -850,9 +1106,34 @@ export class Scene { } const computeIds = [...this.#computePasses.keys()]; - const renderedMeshes = [...this.#meshes.values()].filter( - (mesh) => mesh.vertexCount > 0, - ); + const renderCategory = (material: number) => { + const flags = Math.round(this.array("materials").row(material)[11]); + const transmitted = this.array("materialExtensions").row(material)[0] > 0; + return transmitted ? 1 : (flags & 3) === 2 ? 2 : 0; + }; + const renderedMeshes = [...this.#meshes.values()] + .filter((mesh) => mesh.vertexCount > 0) + .sort( + (left, right) => + renderCategory(Number(this.array("meshInfo").row(left.id)[1])) - + renderCategory(Number(this.array("meshInfo").row(right.id)[1])), + ); + const hasTransmission = renderedMeshes.some((mesh) => { + const materials = new Set([ + Number(this.array("meshInfo").row(mesh.id)[1]), + ...mesh.faceMaterials.values(), + ]); + return [...materials].some( + (material) => this.array("materialExtensions").row(material)[0] > 0, + ); + }); + const compositeStudioBackground = + renderedMeshes.length > 0 && + !hasTransmission && + this.#effects.size === 0 && + this.#shaders.size === 0; + const compositeSceneColor = + hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0; const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm"; addTexture({ id: "hdr", @@ -868,6 +1149,14 @@ export class Scene { usage: ["render"], transient: true, }); + if (compositeSceneColor) + addTexture({ + id: "scene-color", + format: hdrFormat, + size: ["canvas", "canvas", 1], + usage: ["render", "sampled"], + transient: true, + }); addSampler({ id: "linear", magFilter: "linear", minFilter: "linear" }); addSampler({ id: "material-linear", @@ -878,14 +1167,21 @@ export class Scene { addressModeV: "repeat", anisotropyClamp: 16, }); - for (const { number: _, source: __, ...texture } of this.#textures.values()) + for (const { + number: _, + source: __, + sampler, + ...texture + } of this.#textures.values()) { addTexture(texture); + if (sampler) addSampler({ id: `${texture.id}-sampler`, ...sampler }); + } let previous = computeIds.length ? computeIds : ["cluster-lights"]; if (!renderedMeshes.length) { renderPipelines.push({ id: "empty-forward", - code: emptyForwardShader, + code: clearShader, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] }, }); @@ -894,9 +1190,8 @@ export class Scene { type: "render", pipeline: "empty-forward", after: previous, - bindings: [{ group: 0, binding: 0, resource: "accent" }], color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }], - draw: { vertices: 3 }, + draw: { vertices: 0 }, }); previous = ["forward-empty"]; } else { @@ -909,8 +1204,33 @@ export class Scene { ["camera-matrices", "cameraMatrices"], ]) addBuffer({ id, array, usage: ["storage"] }); - const forwardPipelines = new Set(); - let firstRender = true; + if (!compositeStudioBackground) { + renderPipelines.push({ + id: "studio-background", + code: studioBackgroundShader, + vertex: { entry: "vertex" }, + fragment: { entry: "fragment", targets: [{ format: hdrFormat }] }, + }); + passes.push({ + id: "studio-background", + type: "render", + pipeline: "studio-background", + after: previous, + color: [ + { + resource: compositeSceneColor ? "scene-color" : "hdr", + clear: [0, 0, 0, 1], + }, + ], + draw: { vertices: 3 }, + }); + previous = ["studio-background"]; + } + const forwardPipelines = new Map(); + let firstColor = true; + let firstDepth = true; + let sceneColorReady = false; + let forwardTarget = compositeSceneColor ? "scene-color" : "hdr"; for (const mesh of renderedMeshes) { const vertex = `geometry-${mesh.geometryId}-positions`; addBuffer({ @@ -921,14 +1241,20 @@ export class Scene { }); const normal = `geometry-${mesh.geometryId}-normals`; const uv = `geometry-${mesh.geometryId}-uvs`; + const uv2 = `geometry-${mesh.geometryId}-uvs2`; const tangent = `geometry-${mesh.geometryId}-tangents`; + const color = `geometry-${mesh.geometryId}-colors`; const hasNormals = !!this.geometryData(mesh.geometryId, "normals"); const hasUvs = !!this.geometryData(mesh.geometryId, "uvs"); + const hasUvs2 = !!this.geometryData(mesh.geometryId, "uvs2"); const hasTangents = !!this.geometryData(mesh.geometryId, "tangents"); + const hasColors = !!this.geometryData(mesh.geometryId, "colors"); for (const [present, id, kind] of [ [hasNormals, normal, "normals"], [hasUvs, uv, "uvs"], + [hasUvs2, uv2, "uvs2"], [hasTangents, tangent, "tangents"], + [hasColors, color, "colors"], ] as const) if (present) addBuffer({ @@ -977,21 +1303,91 @@ export class Scene { pointers[lane] ? this.#textures.get(Number(pointers[lane]) - 1) : undefined; - const baseTexture = texture(0); - const materialTexture = texture(1); - const normalTexture = hasTangents ? texture(2) : undefined; + const materialTextures = Array.from({ length: 13 }, (_, lane) => + lane === 2 && !hasTangents ? undefined : texture(lane), + ); + const normalTexture = materialTextures[2]; const detailed = hasNormals && hasUvs; - const mask = detailed - ? (baseTexture ? 1 : 0) | - (materialTexture ? 2 : 0) | - (normalTexture ? 4 : 0) + const flags = Math.round(this.array("materials").row(material)[11]); + const extensionInfo = this.array("materialExtensions").row(material); + const transmitted = extensionInfo[0] > 0; + let mask = detailed + ? materialTextures.reduce( + (value, current, lane) => + value | (current === undefined ? 0 : 1 << lane), + 0, + ) : 0; - const pipeline = detailed ? `forward-pbr-${mask}` : "forward-basic"; - if (!forwardPipelines.has(pipeline)) { - forwardPipelines.add(pipeline); + if (!transmitted) mask &= ~((1 << 5) | (1 << 6)); + else if (extensionInfo[2] <= 0) mask &= ~(1 << 6); + if (extensionInfo[8] <= 0) mask &= ~(1 << 7); + if (extensionInfo[16] <= 0) mask &= ~((1 << 8) | (1 << 9)); + if (extensionInfo[20] <= 0) mask &= ~(1 << 10); + if (extensionInfo[11] <= 0) mask &= ~(1 << 4); + if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3); + const tangentSlot = normalTexture ? 3 : -1; + const colorSlot = hasColors ? 3 + Number(!!normalTexture) : -1; + const uv2Slot = hasUvs2 + ? 3 + Number(!!normalTexture) + Number(hasColors) + : -1; + const textureInfo = this.array("materialTextureInfo").row(material); + let clearCompositeLayer = false; + if (transmitted && !sceneColorReady) { + sceneColorReady = true; + if (compositeSceneColor) { + forwardTarget = "hdr"; + clearCompositeLayer = true; + } else { + addTexture({ + id: "scene-color", + format: hdrFormat, + size: ["canvas", "canvas", 1], + usage: ["render", "sampled"], + transient: true, + }); + renderPipelines.push({ + id: "copy-scene-color", + code: copyShader, + vertex: { entry: "vertex" }, + fragment: { + entry: "fragment", + targets: [{ format: hdrFormat }], + }, + }); + passes.push({ + id: "copy-scene-color", + type: "render", + pipeline: "copy-scene-color", + after: previous, + bindings: [ + { group: 0, binding: 0, resource: "hdr" }, + { group: 0, binding: 1, resource: "linear" }, + ], + color: [{ resource: "scene-color", clear: [0, 0, 0, 1] }], + draw: { vertices: 3 }, + }); + previous = ["copy-scene-color"]; + } + } + const code = detailed + ? pbrShader( + mask, + flags, + hasColors, + hasUvs2, + transmitted, + textureInfo, + extensionInfo, + ) + : basicForwardShader; + const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`; + let pipeline = forwardPipelines.get(pipelineKey); + if (!pipeline) { + pipeline = `forward-${detailed ? "pbr" : "basic"}-${forwardPipelines.size}`; + forwardPipelines.set(pipelineKey, pipeline); renderPipelines.push({ id: pipeline, - code: detailed ? pbrShader(mask) : basicForwardShader, + code, vertex: { entry: "vertex", buffers: detailed @@ -1040,6 +1436,34 @@ export class Scene { }, ] : []), + ...(hasColors + ? [ + { + arrayStride: 16, + attributes: [ + { + format: "float32x4", + offset: 0, + shaderLocation: 4, + }, + ], + }, + ] + : []), + ...(hasUvs2 + ? [ + { + arrayStride: 16, + attributes: [ + { + format: "float32x2", + offset: 0, + shaderLocation: 5, + }, + ], + }, + ] + : []), ] : [ { @@ -1056,21 +1480,45 @@ export class Scene { }, fragment: { entry: "fragment", - targets: [{ format: hdrFormat }], + targets: [ + { + format: hdrFormat, + ...((flags & 3) === 2 + ? { + blend: { + color: { + srcFactor: "src-alpha", + dstFactor: "one-minus-src-alpha", + operation: "add", + }, + alpha: { + srcFactor: "one", + dstFactor: "one-minus-src-alpha", + operation: "add", + }, + }, + } + : {}), + }, + ], }, depthStencil: { format: "depth24plus", - depth_write_enabled: true, + depth_write_enabled: (flags & 3) !== 2 && !transmitted, depth_compare: "less", }, + primitive: { + topology: "triangle-list", + frontFace: "ccw", + cullMode: (flags & 4) !== 0 ? null : "back", + conservative: false, + }, }); } - const instance = - (mesh.id + - (draw.material === undefined - ? 0 - : (draw.material + 1) * 65536)) >>> - 0; + const instance = (mesh.id + (material + 1) * 65536) >>> 0; + const samplerTexture = materialTextures.find( + (value, lane) => value !== undefined && (mask & (1 << lane)) !== 0, + ); passes.push({ id, type: "render", @@ -1099,48 +1547,45 @@ export class Scene { : []), ...(detailed && mask ? [ - { group: 1, binding: 0, resource: "material-linear" }, - ...(baseTexture - ? [ - { - group: 1, - binding: 1, - resource: baseTexture.id, - }, - ] - : []), - ...(materialTexture - ? [ - { - group: 1, - binding: 2, - resource: materialTexture.id, - }, - ] - : []), - ...(normalTexture - ? [ - { - group: 1, - binding: 3, - resource: normalTexture.id, - }, - ] - : []), + { + group: 1, + binding: 0, + resource: samplerTexture?.sampler + ? `${samplerTexture.id}-sampler` + : "material-linear", + }, + ...materialTextures.flatMap((value, lane) => + value && (mask & (1 << lane)) !== 0 + ? [ + { + group: 1, + binding: lane + 1, + resource: value.id, + }, + ] + : [], + ), + ] + : []), + ...(detailed && transmitted + ? [ + { group: 2, binding: 0, resource: "scene-color" }, + { group: 2, binding: 1, resource: "linear" }, ] : []), ], color: [ { - resource: "hdr", - ...(firstRender - ? { clear: [0.015, 0.025, 0.05, 1] } + resource: forwardTarget, + ...((firstColor && compositeStudioBackground) || + clearCompositeLayer + ? { clear: [0, 0, 0, 0] } : { load: "load" }), }, ], depth: { resource: "depth", - ...(firstRender ? { clear: 1 } : { load: "load" }), + ...(firstDepth ? { clear: 1 } : { load: "load" }), }, vertexBuffers: [ { slot: 0, resource: vertex }, @@ -1148,7 +1593,13 @@ export class Scene { ? [ { slot: 1, resource: normal }, { slot: 2, resource: uv }, - ...(normalTexture ? [{ slot: 3, resource: tangent }] : []), + ...(normalTexture + ? [{ slot: tangentSlot, resource: tangent }] + : []), + ...(hasColors + ? [{ slot: colorSlot, resource: color }] + : []), + ...(hasUvs2 ? [{ slot: uv2Slot, resource: uv2 }] : []), ] : []), ], @@ -1169,7 +1620,8 @@ export class Scene { } : { vertices: draw.count, instances: 1, firstInstance: instance }, }); - firstRender = false; + firstColor = false; + firstDepth = false; previous = [id]; } } @@ -1237,7 +1689,14 @@ export class Scene { ); renderPipelines.push({ id: "present", - code: presentShader(toneMap), + code: presentShader( + toneMap, + compositeStudioBackground + ? "studio" + : compositeSceneColor + ? "scene" + : undefined, + ), vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: "canvas" }] }, }); @@ -1249,6 +1708,9 @@ export class Scene { 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: [0, 0, 0, 1] }], draw: { vertices: 3 }, diff --git a/addons/handles/src/camera/ArcRotateCamera.ts b/addons/handles/src/camera/ArcRotateCamera.ts index 6d91355..609b144 100644 --- a/addons/handles/src/camera/ArcRotateCamera.ts +++ b/addons/handles/src/camera/ArcRotateCamera.ts @@ -45,30 +45,45 @@ export class ArcRotateCamera extends Camera { }); } - get alpha() { return this.cameraRow()[13]; } + get alpha() { + return this.cameraRow()[13]; + } set alpha(value: number) { const row = this.cameraRow(); - this.#updateTransform(() => { row[13] = value; }); + this.#updateTransform(() => { + row[13] = value; + }); + } + get beta() { + return this.cameraRow()[14]; } - get beta() { return this.cameraRow()[14]; } set beta(value: number) { const row = this.cameraRow(); this.#updateTransform(() => { row[14] = Math.min(Math.PI - 0.001, Math.max(0.001, value)); }); } - get radius() { return this.cameraRow()[15]; } + get radius() { + return this.cameraRow()[15]; + } set radius(value: number) { const row = this.cameraRow(); - this.#updateTransform(() => { row[15] = Math.max(0.01, value); }); + this.#updateTransform(() => { + row[15] = Math.max(0.01, value); + }); } - get target() { return this.#target; } + get target() { + return this.#target; + } set target(value: Node | undefined) { - if (value && (value.scene !== this.scene || value.id < 0)) throw new Error("Target must be a ready Node in this Scene"); + if (value && (value.scene !== this.scene || value.id < 0)) + throw new Error("Target must be a ready Node in this Scene"); this.#target = value; const row = this.cameraRow(); - this.#updateTransform(() => { row[12] = value ? value.id + 1 : 0; }); + this.#updateTransform(() => { + row[12] = value ? value.id + 1 : 0; + }); } attachControls(controls: ArcRotateControls) { @@ -80,9 +95,12 @@ export class ArcRotateCamera extends Camera { controls.element.addEventListener("pointerup", this.#up); controls.element.addEventListener("pointercancel", this.#up); controls.element.addEventListener("contextmenu", this.#contextMenu); - controls.element.addEventListener("wheel", this.#wheel, { passive: false }); + controls.element.addEventListener("wheel", this.#wheel, { + passive: false, + }); } - if (controls.controller) this.#frame = requestAnimationFrame(this.#pollController); + if (controls.controller) + this.#frame = requestAnimationFrame(this.#pollController); return this; } @@ -96,7 +114,7 @@ export class ArcRotateCamera extends Camera { element.removeEventListener("contextmenu", this.#contextMenu); element.removeEventListener("wheel", this.#wheel); } - cancelAnimationFrame(this.#frame); + if (this.#frame) cancelAnimationFrame(this.#frame); this.#frame = 0; this.#pointer = undefined; this.#controls = undefined; @@ -106,11 +124,7 @@ export class ArcRotateCamera extends Camera { const row = this.cameraRow(); if (!this.#target) return row.subarray(16, 19); const position = this.#target.position; - return [ - position.x + row[16], - position.y + row[17], - position.z + row[18], - ]; + return [position.x + row[16], position.y + row[17], position.z + row[18]]; } #updateTransform(change?: () => void) { @@ -130,10 +144,16 @@ export class ArcRotateCamera extends Camera { #down = (event: PointerEvent) => { event.preventDefault(); - this.#pointer = { x: event.clientX, y: event.clientY, button: event.button }; + this.#pointer = { + x: event.clientX, + y: event.clientY, + button: event.button, + }; this.#controls?.element.setPointerCapture?.(event.pointerId); }; - #up = () => { this.#pointer = undefined; }; + #up = () => { + this.#pointer = undefined; + }; #contextMenu = (event: Event) => event.preventDefault(); #move = (event: PointerEvent) => { if (!this.#pointer || !this.#controls) return; @@ -144,9 +164,17 @@ export class ArcRotateCamera extends Camera { if (this.#pointer.button === 2) { const row = this.cameraRow(); this.#updateTransform(() => { - const speed = this.#controls?.panSpeed ?? 0.005; - const horizontal = -x * speed; - const vertical = y * speed; + const element = this.#controls!.element; + const verticalExtent = + this.projection === "orthographic" + ? this.orthoSize + : 2 * this.radius * Math.tan(this.fov / 2); + const speed = this.#controls?.panSpeed ?? 1; + const horizontal = + (-x * verticalExtent * this.aspect * speed) / + Math.max(1, element.clientWidth); + const vertical = + (y * verticalExtent * speed) / Math.max(1, element.clientHeight); const sinAlpha = Math.sin(row[13]); const cosAlpha = Math.cos(row[13]); const sinBeta = Math.sin(row[14]); @@ -185,17 +213,12 @@ export class ArcRotateCamera extends Camera { row[13] += (pad.axes[2] ?? pad.axes[0] ?? 0) * 0.03; row[14] = Math.min( Math.PI - 0.001, - Math.max( - 0.001, - row[14] + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03, - ), + Math.max(0.001, row[14] + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03), ); row[15] = Math.max( 0.01, row[15] + - ((pad.buttons[6]?.value ?? 0) - - (pad.buttons[7]?.value ?? 0)) * - 0.1, + ((pad.buttons[6]?.value ?? 0) - (pad.buttons[7]?.value ?? 0)) * 0.1, ); }); } diff --git a/addons/handles/src/importers/gltf.ts b/addons/handles/src/importers/gltf.ts index 1e66324..fceeb86 100644 --- a/addons/handles/src/importers/gltf.ts +++ b/addons/handles/src/importers/gltf.ts @@ -1,5 +1,8 @@ import { Mesh } from "../Mesh"; import type { Scene } from "../Scene"; +import { DirectionalLight } from "../lights/DirectionalLight"; +import { PointLight } from "../lights/PointLight"; +import { SpotLight } from "../lights/SpotLight"; import { PBRMaterial } from "../materials/PBRMaterial"; import { Texture } from "../materials/Texture"; @@ -49,12 +52,20 @@ export async function importGltf(scene: Scene, url: string | URL) { for (const material of result.materials) { if (material.baseColorTexture >= 0) srgb.add(material.baseColorTexture); if (material.emissiveTexture >= 0) srgb.add(material.emissiveTexture); + if (material.flags & 16 && material.metallicRoughnessTexture >= 0) + srgb.add(material.metallicRoughnessTexture); + if (material.specularColorTexture >= 0) + srgb.add(material.specularColorTexture); } textures = result.textures.map( - ({ image }: { image: ImageBitmap }, index: number) => + ( + { image, sampler }: { image: ImageBitmap; sampler: any }, + index: number, + ) => new Texture(scene, { source: image, format: srgb.has(index) ? "rgba8unorm-srgb" : "rgba8unorm", + sampler, }), ); await Promise.all(textures.map((texture: Texture) => texture.ready)); @@ -63,10 +74,19 @@ export async function importGltf(scene: Scene, url: string | URL) { new PBRMaterial(scene, { ...options, baseColorTexture: textures[options.baseColorTexture], - metallicRoughnessTexture: - textures[options.metallicRoughnessTexture], + metallicRoughnessTexture: textures[options.metallicRoughnessTexture], normalTexture: textures[options.normalTexture], emissiveTexture: textures[options.emissiveTexture], + occlusionTexture: textures[options.occlusionTexture], + transmissionTexture: textures[options.transmissionTexture], + thicknessTexture: textures[options.thicknessTexture], + clearcoatTexture: textures[options.clearcoatTexture], + iridescenceTexture: textures[options.iridescenceTexture], + iridescenceThicknessTexture: + textures[options.iridescenceThicknessTexture], + anisotropyTexture: textures[options.anisotropyTexture], + specularTexture: textures[options.specularTexture], + specularColorTexture: textures[options.specularColorTexture], }), ); await Promise.all(materials.map((material: PBRMaterial) => material.ready)); @@ -83,12 +103,20 @@ export async function importGltf(scene: Scene, url: string | URL) { ...(primitive.normals ? { normals: primitive.normals } : {}), ...(primitive.tangents ? { tangents: primitive.tangents } : {}), ...(primitive.uvs ? { uvs: primitive.uvs } : {}), + ...(primitive.uvs2 ? { uvs2: primitive.uvs2 } : {}), ...(primitive.colors ? { colors: primitive.colors } : {}), }, }), ); await Promise.all(meshes.map((mesh) => mesh.ready)); - return meshes; + const lights = (result.lights ?? []).map((options: any) => { + if (options.type === "directional") + return new DirectionalLight(scene, options); + if (options.type === "spot") return new SpotLight(scene, options); + return new PointLight(scene, options); + }); + await Promise.all(lights.map((light: any) => light.ready)); + return Object.assign(meshes, { camera: result.camera }); }); await Promise.all( result.textures.map((_: unknown, index: number) => diff --git a/addons/handles/src/importers/worker.ts b/addons/handles/src/importers/worker.ts index b6ebcbb..204aab6 100644 --- a/addons/handles/src/importers/worker.ts +++ b/addons/handles/src/importers/worker.ts @@ -1,7 +1,15 @@ export {}; const decoder = new TextDecoder(); -const widths: Record = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }; +const widths: Record = { + SCALAR: 1, + VEC2: 2, + VEC3: 3, + VEC4: 4, + MAT2: 4, + MAT3: 9, + MAT4: 16, +}; const sizes: Record = { 5120: 1, 5121: 1, @@ -29,79 +37,165 @@ function component(view: DataView, offset: number, type: number) { throw new Error("GLTF_COMPONENT"); } -function rotate(rotor: number[], value: number[]) { - const [rx, ry, rz, rw] = rotor; - const [x, y, z] = value; - const tx = 2 * (ry * z - rz * y); - const ty = 2 * (rz * x - rx * z); - const tz = 2 * (rx * y - ry * x); - return [ - x + rw * tx + ry * tz - rz * ty, - y + rw * ty + rz * tx - rx * tz, - z + rw * tz + rx * ty - ry * tx, - ]; -} +const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; -function multiply(a: number[], b: number[]) { - return [ - a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], - a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], - a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], - a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], - ]; -} - -function decompose(matrix: number[]) { - const scale = [ - Math.hypot(matrix[0], matrix[1], matrix[2]), - Math.hypot(matrix[4], matrix[5], matrix[6]), - Math.hypot(matrix[8], matrix[9], matrix[10]), - ]; - const [sx, sy, sz] = scale; - const [m00, m01, m02] = [matrix[0] / sx, matrix[4] / sy, matrix[8] / sz]; - const [m10, m11, m12] = [matrix[1] / sx, matrix[5] / sy, matrix[9] / sz]; - const [m20, m21, m22] = [matrix[2] / sx, matrix[6] / sy, matrix[10] / sz]; - let rotor: number[]; - if (m00 + m11 + m22 > 0) { - const s = Math.sqrt(1 + m00 + m11 + m22) * 2; - rotor = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4]; - } else if (m00 > m11 && m00 > m22) { - const s = Math.sqrt(1 + m00 - m11 - m22) * 2; - rotor = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s]; - } else if (m11 > m22) { - const s = Math.sqrt(1 + m11 - m00 - m22) * 2; - rotor = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s]; - } else { - const s = Math.sqrt(1 + m22 - m00 - m11) * 2; - rotor = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s]; - } - return { position: matrix.slice(12, 15), rotor, scale }; +function multiply(a: ArrayLike, b: ArrayLike) { + const result = new Array(16); + for (let column = 0; column < 4; column++) + for (let row = 0; row < 4; row++) { + let value = 0; + for (let lane = 0; lane < 4; lane++) + value += a[lane * 4 + row] * b[column * 4 + lane]; + result[column * 4 + row] = value; + } + return result; } function transform(node: any) { - return node.matrix - ? decompose(node.matrix) - : { - position: node.translation ?? [0, 0, 0], - rotor: node.rotation ?? [0, 0, 0, 1], - scale: node.scale ?? [1, 1, 1], - }; + if (node.matrix) return Array.from(node.matrix); + const [x, y, z, w] = node.rotation ?? [0, 0, 0, 1]; + const [sx, sy, sz] = node.scale ?? [1, 1, 1]; + const [tx, ty, tz] = node.translation ?? [0, 0, 0]; + const xx = x * x; + const yy = y * y; + const zz = z * z; + const xy = x * y; + const xz = x * z; + const yz = y * z; + const wx = w * x; + const wy = w * y; + const wz = w * z; + return [ + (1 - 2 * (yy + zz)) * sx, + 2 * (xy + wz) * sx, + 2 * (xz - wy) * sx, + 0, + 2 * (xy - wz) * sy, + (1 - 2 * (xx + zz)) * sy, + 2 * (yz + wx) * sy, + 0, + 2 * (xz + wy) * sz, + 2 * (yz - wx) * sz, + (1 - 2 * (xx + yy)) * sz, + 0, + tx, + ty, + tz, + 1, + ]; } -function compose(parent: any, local: any) { - const position = rotate( - parent.rotor, - local.position.map( - (value: number, lane: number) => value * parent.scale[lane], - ), +function determinant(matrix: ArrayLike) { + return ( + matrix[0] * (matrix[5] * matrix[10] - matrix[6] * matrix[9]) - + matrix[4] * (matrix[1] * matrix[10] - matrix[2] * matrix[9]) + + matrix[8] * (matrix[1] * matrix[6] - matrix[2] * matrix[5]) ); - return { - position: position.map((value, lane) => value + parent.position[lane]), - rotor: multiply(parent.rotor, local.rotor), - scale: local.scale.map( - (value: number, lane: number) => value * parent.scale[lane], - ), - }; +} + +function point(matrix: ArrayLike, source: ArrayLike) { + return [ + matrix[0] * source[0] + + matrix[4] * source[1] + + matrix[8] * source[2] + + matrix[12], + matrix[1] * source[0] + + matrix[5] * source[1] + + matrix[9] * source[2] + + matrix[13], + matrix[2] * source[0] + + matrix[6] * source[1] + + matrix[10] * source[2] + + matrix[14], + ]; +} + +function vector(matrix: ArrayLike, source: ArrayLike) { + const x = + matrix[0] * source[0] + matrix[4] * source[1] + matrix[8] * source[2]; + const y = + matrix[1] * source[0] + matrix[5] * source[1] + matrix[9] * source[2]; + const z = + matrix[2] * source[0] + matrix[6] * source[1] + matrix[10] * source[2]; + const length = Math.hypot(x, y, z) || 1; + return [x / length, y / length, z / length]; +} + +function direction(matrix: ArrayLike, source: ArrayLike) { + const a = matrix[0]; + const b = matrix[4]; + const c = matrix[8]; + const d = matrix[1]; + const e = matrix[5]; + const f = matrix[9]; + const g = matrix[2]; + const h = matrix[6]; + const i = matrix[10]; + const inverse = 1 / determinant(matrix); + const x = + ((e * i - f * h) * source[0] + + (f * g - d * i) * source[1] + + (d * h - e * g) * source[2]) * + inverse; + const y = + ((c * h - b * i) * source[0] + + (a * i - c * g) * source[1] + + (b * g - a * h) * source[2]) * + inverse; + const z = + ((b * f - c * e) * source[0] + + (c * d - a * f) * source[1] + + (a * e - b * d) * source[2]) * + inverse; + const length = Math.hypot(x, y, z) || 1; + return [x / length, y / length, z / length]; +} + +function rotor(matrix: ArrayLike) { + const sx = Math.hypot(matrix[0], matrix[1], matrix[2]) || 1; + const sy = Math.hypot(matrix[4], matrix[5], matrix[6]) || 1; + const sz = Math.hypot(matrix[8], matrix[9], matrix[10]) || 1; + const m00 = matrix[0] / sx; + const m01 = matrix[4] / sy; + const m02 = matrix[8] / sz; + const m10 = matrix[1] / sx; + const m11 = matrix[5] / sy; + const m12 = matrix[9] / sz; + const m20 = matrix[2] / sx; + const m21 = matrix[6] / sy; + const m22 = matrix[10] / sz; + const trace = m00 + m11 + m22; + let x: number; + let y: number; + let z: number; + let w: number; + if (trace > 0) { + const scale = Math.sqrt(trace + 1) * 2; + x = (m21 - m12) / scale; + y = (m02 - m20) / scale; + z = (m10 - m01) / scale; + w = scale / 4; + } else if (m00 > m11 && m00 > m22) { + const scale = Math.sqrt(1 + m00 - m11 - m22) * 2; + x = scale / 4; + y = (m01 + m10) / scale; + z = (m02 + m20) / scale; + w = (m21 - m12) / scale; + } else if (m11 > m22) { + const scale = Math.sqrt(1 + m11 - m00 - m22) * 2; + x = (m01 + m10) / scale; + y = scale / 4; + z = (m12 + m21) / scale; + w = (m02 - m20) / scale; + } else { + const scale = Math.sqrt(1 + m22 - m00 - m11) * 2; + x = (m02 + m20) / scale; + y = (m12 + m21) / scale; + z = scale / 4; + w = (m10 - m01) / scale; + } + const length = Math.hypot(x, y, z, w) || 1; + return [x / length, y / length, z / length, w / length]; } function parse(bytes: Uint8Array) { @@ -127,7 +221,127 @@ function parse(bytes: Uint8Array) { return { document, binary }; } -async function load(url: string) { +function computeNormals(positions: Float32Array, indices: Uint32Array) { + const normals = new Float32Array(positions.length); + for (let face = 0; face + 2 < indices.length; face += 3) { + const a = indices[face] * 3; + const b = indices[face + 1] * 3; + const c = indices[face + 2] * 3; + const ab = [ + positions[b] - positions[a], + positions[b + 1] - positions[a + 1], + positions[b + 2] - positions[a + 2], + ]; + const ac = [ + positions[c] - positions[a], + positions[c + 1] - positions[a + 1], + positions[c + 2] - positions[a + 2], + ]; + const cross = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + for (const vertex of [a, b, c]) + for (let lane = 0; lane < 3; lane++) + normals[vertex + lane] += cross[lane]; + } + for (let vertex = 0; vertex < normals.length; vertex += 3) { + const length = + Math.hypot(normals[vertex], normals[vertex + 1], normals[vertex + 2]) || + 1; + for (let lane = 0; lane < 3; lane++) normals[vertex + lane] /= length; + } + return normals; +} + +function computeTangents( + positions: Float32Array, + normals: Float32Array, + uvs: Float32Array, + indices: Uint32Array, +) { + const first = new Float32Array(positions.length); + const second = new Float32Array(positions.length); + for (let face = 0; face + 2 < indices.length; face += 3) { + const ia = indices[face]; + const ib = indices[face + 1]; + const ic = indices[face + 2]; + const a = ia * 3; + const b = ib * 3; + const c = ic * 3; + const au = ia * 2; + const bu = ib * 2; + const cu = ic * 2; + const x1 = positions[b] - positions[a]; + const x2 = positions[c] - positions[a]; + const y1 = positions[b + 1] - positions[a + 1]; + const y2 = positions[c + 1] - positions[a + 1]; + const z1 = positions[b + 2] - positions[a + 2]; + const z2 = positions[c + 2] - positions[a + 2]; + const s1 = uvs[bu] - uvs[au]; + const s2 = uvs[cu] - uvs[au]; + const t1 = uvs[bu + 1] - uvs[au + 1]; + const t2 = uvs[cu + 1] - uvs[au + 1]; + const divisor = s1 * t2 - s2 * t1; + if (Math.abs(divisor) < 1e-10) continue; + const r = 1 / divisor; + const tangent = [ + (t2 * x1 - t1 * x2) * r, + (t2 * y1 - t1 * y2) * r, + (t2 * z1 - t1 * z2) * r, + ]; + const bitangent = [ + (s1 * x2 - s2 * x1) * r, + (s1 * y2 - s2 * y1) * r, + (s1 * z2 - s2 * z1) * r, + ]; + for (const vertex of [a, b, c]) + for (let lane = 0; lane < 3; lane++) { + first[vertex + lane] += tangent[lane]; + second[vertex + lane] += bitangent[lane]; + } + } + const tangents = new Float32Array((positions.length / 3) * 4); + for (let vertex = 0; vertex < positions.length / 3; vertex++) { + const at = vertex * 3; + const output = vertex * 4; + const normal = normals.subarray(at, at + 3); + const source = first.subarray(at, at + 3); + const projection = + normal[0] * source[0] + normal[1] * source[1] + normal[2] * source[2]; + let x = source[0] - normal[0] * projection; + let y = source[1] - normal[1] * projection; + let z = source[2] - normal[2] * projection; + const length = Math.hypot(x, y, z) || 1; + x /= length; + y /= length; + z /= length; + tangents[output] = x; + tangents[output + 1] = y; + tangents[output + 2] = z; + tangents[output + 3] = + normal[0] * (y * second[at + 2] - z * second[at + 1]) + + normal[1] * (z * second[at] - x * second[at + 2]) + + normal[2] * (x * second[at + 1] - y * second[at]) < + 0 + ? -1 + : 1; + } + return tangents; +} + +function color4(values: Float32Array, width: number) { + if (width === 4) return values; + const result = new Float32Array((values.length / width) * 4); + for (let vertex = 0; vertex < values.length / width; vertex++) { + result.set(values.subarray(vertex * width, vertex * width + 3), vertex * 4); + result[vertex * 4 + 3] = 1; + } + return result; +} + +export async function loadGltf(url: string) { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP_${response.status}`); const { document, binary } = parse( @@ -159,152 +373,522 @@ async function load(url: string) { return result.blob(); }), ); + const samplerDescriptor = (id: number | undefined) => { + const sampler = id === undefined ? {} : (document.samplers?.[id] ?? {}); + const addressMode = (mode: number | undefined) => + mode === 33071 + ? "clamp-to-edge" + : mode === 33648 + ? "mirror-repeat" + : "repeat"; + return { + magFilter: sampler.magFilter === 9728 ? "nearest" : "linear", + minFilter: + sampler.minFilter === 9728 || + sampler.minFilter === 9984 || + sampler.minFilter === 9986 + ? "nearest" + : "linear", + mipmapFilter: + sampler.minFilter === 9984 || sampler.minFilter === 9985 + ? "nearest" + : "linear", + addressModeU: addressMode(sampler.wrapS), + addressModeV: addressMode(sampler.wrapT), + }; + }; const textures = await Promise.all( (document.textures ?? []).map(async (texture: any) => ({ image: await createImageBitmap(imageBlobs[texture.source]), + sampler: samplerDescriptor(texture.sampler), })), ); + const normalized = (value: number, type: number) => { + if (type === 5120) return Math.max(value / 127, -1); + if (type === 5121) return value / 255; + if (type === 5122) return Math.max(value / 32_767, -1); + if (type === 5123) return value / 65_535; + return value; + }; const accessor = (id: number, integer = false) => { const source = document.accessors[id]; const width = widths[source.type]; const size = sizes[source.componentType]; - const bufferView = document.bufferViews[source.bufferView]; - const bytes = buffers[bufferView.buffer]; - const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0); - const stride = bufferView.byteStride ?? width * size; - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const values = integer ? new Uint32Array(source.count * width) : new Float32Array(source.count * width); - if ( - !source.normalized && - stride === width * size && - (bytes.byteOffset + start) % size === 0 - ) { - const packed = new arrays[source.componentType]( + if (source.bufferView !== undefined) { + const bufferView = document.bufferViews[source.bufferView]; + const bytes = buffers[bufferView.buffer]; + const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0); + const stride = bufferView.byteStride ?? width * size; + const view = new DataView( bytes.buffer, - bytes.byteOffset + start, - source.count * width, + bytes.byteOffset, + bytes.byteLength, ); - values.set(packed); - return values; - } - for (let item = 0; item < source.count; item++) - for (let lane = 0; lane < width; lane++) { - let value = component( - view, - start + item * stride + lane * size, - source.componentType, + if ( + !source.sparse && + !source.normalized && + stride === width * size && + (bytes.byteOffset + start) % size === 0 + ) { + values.set( + new arrays[source.componentType]( + bytes.buffer, + bytes.byteOffset + start, + source.count * width, + ), ); - if (!integer && source.normalized) { - const maximum = - source.componentType === 5121 - ? 255 - : source.componentType === 5123 - ? 65535 - : 1; - value /= maximum; + } else + for (let item = 0; item < source.count; item++) + for (let lane = 0; lane < width; lane++) { + let value = component( + view, + start + item * stride + lane * size, + source.componentType, + ); + if (!integer && source.normalized) + value = normalized(value, source.componentType); + values[item * width + lane] = value; + } + } + if (source.sparse) { + const sparse = source.sparse; + const indexView = document.bufferViews[sparse.indices.bufferView]; + const indexBytes = buffers[indexView.buffer]; + const indexStart = + (indexView.byteOffset ?? 0) + (sparse.indices.byteOffset ?? 0); + const indexData = new DataView( + indexBytes.buffer, + indexBytes.byteOffset, + indexBytes.byteLength, + ); + const indexSize = sizes[sparse.indices.componentType]; + const valueView = document.bufferViews[sparse.values.bufferView]; + const valueBytes = buffers[valueView.buffer]; + const valueStart = + (valueView.byteOffset ?? 0) + (sparse.values.byteOffset ?? 0); + const valueData = new DataView( + valueBytes.buffer, + valueBytes.byteOffset, + valueBytes.byteLength, + ); + for (let item = 0; item < sparse.count; item++) { + const target = component( + indexData, + indexStart + item * indexSize, + sparse.indices.componentType, + ); + for (let lane = 0; lane < width; lane++) { + let value = component( + valueData, + valueStart + (item * width + lane) * size, + source.componentType, + ); + if (!integer && source.normalized) + value = normalized(value, source.componentType); + values[target * width + lane] = value; } - values[item * width + lane] = value; } + } return values; }; - const materials = (document.materials ?? []).map((material: any) => { - const pbr = material.pbrMetallicRoughness ?? {}; + const textureInfo = (info: any) => { + if (!info) return undefined; + const extension = info.extensions?.KHR_texture_transform ?? {}; return { - baseColor: pbr.baseColorFactor ?? [1, 1, 1, 1], - metallic: pbr.metallicFactor ?? 0, - roughness: pbr.roughnessFactor ?? 0.7, + texture: info.index, + texCoord: extension.texCoord ?? info.texCoord ?? 0, + offset: extension.offset ?? [0, 0], + scale: extension.scale ?? [1, 1], + rotation: extension.rotation ?? 0, + }; + }; + const materials = (document.materials ?? []).map((material: any) => { + const extensions = material.extensions ?? {}; + const specGloss = extensions.KHR_materials_pbrSpecularGlossiness; + const pbr = material.pbrMetallicRoughness ?? {}; + const transmission = extensions.KHR_materials_transmission ?? {}; + const volume = extensions.KHR_materials_volume ?? {}; + const clearcoat = extensions.KHR_materials_clearcoat ?? {}; + const sheen = extensions.KHR_materials_sheen ?? {}; + const specular = extensions.KHR_materials_specular ?? {}; + const iridescence = extensions.KHR_materials_iridescence ?? {}; + const anisotropy = extensions.KHR_materials_anisotropy ?? {}; + const alphaMode = + material.alphaMode === "MASK" + ? 1 + : material.alphaMode === "BLEND" + ? 2 + : 0; + const flags = + alphaMode | + (material.doubleSided ? 4 : 0) | + (extensions.KHR_materials_unlit ? 8 : 0) | + (specGloss ? 16 : 0); + const infos = [ + textureInfo(specGloss?.diffuseTexture ?? pbr.baseColorTexture), + textureInfo( + specGloss?.specularGlossinessTexture ?? pbr.metallicRoughnessTexture, + ), + textureInfo(material.normalTexture), + textureInfo(material.emissiveTexture), + textureInfo(material.occlusionTexture), + textureInfo(transmission.transmissionTexture), + textureInfo(volume.thicknessTexture), + textureInfo(clearcoat.clearcoatTexture), + textureInfo(iridescence.iridescenceTexture), + textureInfo(iridescence.iridescenceThicknessTexture), + textureInfo(anisotropy.anisotropyTexture), + textureInfo(specular.specularTexture), + textureInfo(specular.specularColorTexture), + ]; + return { + baseColor: specGloss?.diffuseFactor ?? + pbr.baseColorFactor ?? [1, 1, 1, 1], + metallic: specGloss ? 0 : (pbr.metallicFactor ?? 1), + roughness: specGloss + ? 1 - (specGloss.glossinessFactor ?? 1) + : (pbr.roughnessFactor ?? 1), emissive: material.emissiveFactor ?? [0, 0, 0], + normalScale: material.normalTexture?.scale ?? 1, alphaCutoff: material.alphaCutoff ?? 0.5, - baseColorTexture: pbr.baseColorTexture?.index ?? -1, - metallicRoughnessTexture: pbr.metallicRoughnessTexture?.index ?? -1, - normalTexture: material.normalTexture?.index ?? -1, - emissiveTexture: material.emissiveTexture?.index ?? -1, + flags, + occlusionStrength: material.occlusionTexture?.strength ?? 1, + transmission: transmission.transmissionFactor ?? 0, + ior: extensions.KHR_materials_ior?.ior ?? 1.5, + thickness: volume.thicknessFactor ?? 0, + attenuationDistance: volume.attenuationDistance ?? 0, + attenuationColor: volume.attenuationColor ?? [1, 1, 1], + emissiveStrength: + extensions.KHR_materials_emissive_strength?.emissiveStrength ?? 1, + clearcoat: clearcoat.clearcoatFactor ?? 0, + clearcoatRoughness: clearcoat.clearcoatRoughnessFactor ?? 0, + specular: specular.specularFactor ?? 1, + sheenColor: sheen.sheenColorFactor ?? [0, 0, 0], + sheenRoughness: sheen.sheenRoughnessFactor ?? 0, + specularColor: specGloss?.specularFactor ?? [1, 1, 1], + iridescence: iridescence.iridescenceFactor ?? 0, + iridescenceIor: iridescence.iridescenceIor ?? 1.3, + iridescenceThicknessMinimum: + iridescence.iridescenceThicknessMinimum ?? 100, + iridescenceThicknessMaximum: + iridescence.iridescenceThicknessMaximum ?? 400, + anisotropy: anisotropy.anisotropyStrength ?? 0, + anisotropyRotation: anisotropy.anisotropyRotation ?? 0, + specularExtensionColor: specular.specularColorFactor ?? [1, 1, 1], + textureInfos: infos, + baseColorTexture: infos[0]?.texture ?? -1, + metallicRoughnessTexture: infos[1]?.texture ?? -1, + normalTexture: infos[2]?.texture ?? -1, + emissiveTexture: infos[3]?.texture ?? -1, + occlusionTexture: infos[4]?.texture ?? -1, + transmissionTexture: infos[5]?.texture ?? -1, + thicknessTexture: infos[6]?.texture ?? -1, + clearcoatTexture: infos[7]?.texture ?? -1, + iridescenceTexture: infos[8]?.texture ?? -1, + iridescenceThicknessTexture: infos[9]?.texture ?? -1, + anisotropyTexture: infos[10]?.texture ?? -1, + specularTexture: infos[11]?.texture ?? -1, + specularColorTexture: infos[12]?.texture ?? -1, }; }); + + const nodes = document.nodes ?? []; + const parents = new Map(); + for (let id = 0; id < nodes.length; id++) + for (const child of nodes[id].children ?? []) parents.set(child, id); + const worlds = new Map(); + const world = (id: number): number[] => { + const existing = worlds.get(id); + if (existing) return existing; + const local = transform(nodes[id] ?? {}); + const parent = parents.get(id); + const result = + parent === undefined ? local : multiply(world(parent), local); + worlds.set(id, result); + return result; + }; + for (let id = 0; id < nodes.length; id++) world(id); + const primitives: any[] = []; - const emitMesh = (meshId: number, transform: any) => { + const emitMesh = (meshId: number, nodeId: number, matrix: number[]) => { const mesh = document.meshes?.[meshId]; + const node = nodes[nodeId] ?? {}; + const skin = + node.skin === undefined ? undefined : document.skins?.[node.skin]; + const inverseBindMatrices = + skin?.inverseBindMatrices === undefined + ? undefined + : (accessor(skin.inverseBindMatrices) as Float32Array); + const jointMatrices = skin?.joints.map((joint: number, index: number) => + multiply( + world(joint), + inverseBindMatrices?.subarray(index * 16, index * 16 + 16) ?? identity, + ), + ); for (const primitive of mesh?.primitives ?? []) { if ( (primitive.mode ?? 4) !== 4 || primitive.attributes.POSITION === undefined ) continue; - const positions = accessor(primitive.attributes.POSITION); + const positions = accessor(primitive.attributes.POSITION) as Float32Array; + let normals = + primitive.attributes.NORMAL === undefined + ? undefined + : (accessor(primitive.attributes.NORMAL) as Float32Array); + let tangents = + primitive.attributes.TANGENT === undefined + ? undefined + : (accessor(primitive.attributes.TANGENT) as Float32Array); + const uvs = + primitive.attributes.TEXCOORD_0 === undefined + ? new Float32Array((positions.length / 3) * 2) + : (accessor(primitive.attributes.TEXCOORD_0) as Float32Array); + const uvs2 = + primitive.attributes.TEXCOORD_1 === undefined + ? undefined + : (accessor(primitive.attributes.TEXCOORD_1) as Float32Array); const indices = primitive.indices === undefined ? Uint32Array.from( { length: positions.length / 3 }, (_, index) => index, ) - : accessor(primitive.indices, true); + : (accessor(primitive.indices, true) as Uint32Array); + const weights = node.weights ?? mesh.weights ?? []; + for ( + let target = 0; + target < (primitive.targets?.length ?? 0); + target++ + ) { + const weight = weights[target] ?? 0; + if (!weight) continue; + for (const [semantic, values] of [ + ["POSITION", positions], + ["NORMAL", normals], + ["TANGENT", tangents], + ] as const) { + const targetAccessor = primitive.targets[target][semantic]; + if (targetAccessor === undefined || !values) continue; + const delta = accessor(targetAccessor) as Float32Array; + if (semantic === "TANGENT") { + for (let vertex = 0; vertex < delta.length / 3; vertex++) + for (let lane = 0; lane < 3; lane++) + values[vertex * 4 + lane] += delta[vertex * 3 + lane] * weight; + } else { + for (let index = 0; index < delta.length; index++) + values[index] += delta[index] * weight; + } + } + } + + const jointAccessor = primitive.attributes.JOINTS_0; + const weightAccessor = primitive.attributes.WEIGHTS_0; + if ( + jointMatrices && + jointAccessor !== undefined && + weightAccessor !== undefined + ) { + const joints = accessor(jointAccessor, true) as Uint32Array; + const weights = accessor(weightAccessor) as Float32Array; + for (let vertex = 0; vertex < positions.length / 3; vertex++) { + const position = [0, 0, 0]; + const normal = [0, 0, 0]; + const tangent = [0, 0, 0]; + let total = 0; + for (let influence = 0; influence < 4; influence++) { + const weight = weights[vertex * 4 + influence]; + const joint = jointMatrices[joints[vertex * 4 + influence]]; + if (!weight || !joint) continue; + const skinnedPosition = point( + joint, + positions.subarray(vertex * 3, vertex * 3 + 3), + ); + const skinnedNormal = normals + ? direction(joint, normals.subarray(vertex * 3, vertex * 3 + 3)) + : undefined; + const skinnedTangent = tangents + ? vector(joint, tangents.subarray(vertex * 4, vertex * 4 + 3)) + : undefined; + for (let lane = 0; lane < 3; lane++) { + position[lane] += skinnedPosition[lane] * weight; + if (skinnedNormal) normal[lane] += skinnedNormal[lane] * weight; + if (skinnedTangent) + tangent[lane] += skinnedTangent[lane] * weight; + } + total += weight; + } + if (!total) continue; + positions.set( + position.map((value) => value / total), + vertex * 3, + ); + if (normals) { + const length = Math.hypot(...normal) || 1; + normals.set( + normal.map((value) => value / length), + vertex * 3, + ); + } + if (tangents) { + const length = Math.hypot(...tangent) || 1; + tangents.set( + tangent.map((value) => value / length), + vertex * 4, + ); + } + } + matrix = identity; + } + + const mirrored = determinant(matrix) < 0; + for (let vertex = 0; vertex < positions.length; vertex += 3) + positions.set( + point(matrix, positions.subarray(vertex, vertex + 3)), + vertex, + ); + if (normals) + for (let vertex = 0; vertex < normals.length; vertex += 3) + normals.set( + direction(matrix, normals.subarray(vertex, vertex + 3)), + vertex, + ); + if (tangents) + for (let vertex = 0; vertex < tangents.length; vertex += 4) { + tangents.set( + vector(matrix, tangents.subarray(vertex, vertex + 3)), + vertex, + ); + if (mirrored) tangents[vertex + 3] *= -1; + } + if (mirrored) + for (let face = 0; face + 2 < indices.length; face += 3) + [indices[face + 1], indices[face + 2]] = [ + indices[face + 2], + indices[face + 1], + ]; + normals ??= computeNormals(positions, indices); + const material = materials[primitive.material ?? -1]; + if (!tangents && material?.normalTexture >= 0) + tangents = computeTangents(positions, normals, uvs, indices); + const colorAccessor = primitive.attributes.COLOR_0; primitives.push({ positions, + normals, + tangents, + uvs, + uvs2, indices, - ...(primitive.attributes.NORMAL === undefined + ...(colorAccessor === undefined ? {} - : { normals: accessor(primitive.attributes.NORMAL) }), - ...(primitive.attributes.TANGENT === undefined - ? {} - : { tangents: accessor(primitive.attributes.TANGENT) }), - ...(primitive.attributes.TEXCOORD_0 === undefined - ? {} - : { uvs: accessor(primitive.attributes.TEXCOORD_0) }), - ...(primitive.attributes.COLOR_0 === undefined - ? {} - : { colors: accessor(primitive.attributes.COLOR_0) }), + : { + colors: color4( + accessor(colorAccessor) as Float32Array, + widths[document.accessors[colorAccessor].type], + ), + }), material: primitive.material ?? -1, - ...transform, + position: [0, 0, 0], + rotor: [0, 0, 0, 1], + scale: [1, 1, 1], }); } }; - const nodes = document.nodes ?? []; - const scene = document.scenes?.[document.scene ?? 0]; - const children = new Set(nodes.flatMap((node: any) => node.children ?? [])); - const roots = - scene?.nodes ?? - nodes - .map((_: any, id: number) => id) - .filter((id: number) => !children.has(id)); - const visit = ( - id: number, - parent = { - position: [0, 0, 0], - rotor: [0, 0, 0, 1], - scale: [1, 1, 1], - }, - ) => { - const node = nodes[id] ?? {}; - const world = compose(parent, transform(node)); - if (node.mesh !== undefined) emitMesh(node.mesh, world); - for (const child of node.children ?? []) visit(child, world); + const emitNode = (id: number) => { + const node = nodes[id]; + if (node.mesh === undefined) return; + const instances = node.extensions?.EXT_mesh_gpu_instancing?.attributes; + if (!instances) return emitMesh(node.mesh, id, world(id)); + const translations = + instances.TRANSLATION === undefined + ? undefined + : (accessor(instances.TRANSLATION) as Float32Array); + const rotations = + instances.ROTATION === undefined + ? undefined + : (accessor(instances.ROTATION) as Float32Array); + const scales = + instances.SCALE === undefined + ? undefined + : (accessor(instances.SCALE) as Float32Array); + const count = translations + ? translations.length / 3 + : rotations + ? rotations.length / 4 + : scales + ? scales.length / 3 + : 0; + for (let instance = 0; instance < count; instance++) + emitMesh( + node.mesh, + id, + multiply( + world(id), + transform({ + translation: translations?.subarray(instance * 3, instance * 3 + 3), + rotation: rotations?.subarray(instance * 4, instance * 4 + 4), + scale: scales?.subarray(instance * 3, instance * 3 + 3), + }), + ), + ); }; - for (const root of roots) visit(root); + for (let id = 0; id < nodes.length; id++) emitNode(id); if (!nodes.length) for (let id = 0; id < (document.meshes ?? []).length; id++) - emitMesh(id, {}); - return { materials, primitives, textures }; + emitMesh(id, -1, identity); + const lightDefinitions = + document.extensions?.KHR_lights_punctual?.lights ?? []; + const lights = nodes.flatMap((node: any, id: number) => { + const lightId = node.extensions?.KHR_lights_punctual?.light; + const light = lightId === undefined ? undefined : lightDefinitions[lightId]; + if (!light) return []; + const matrix = world(id); + return [ + { + type: light.type, + color: light.color ?? [1, 1, 1], + intensity: light.intensity ?? 1, + range: light.range ?? 1_000_000, + innerAngle: light.spot?.innerConeAngle ?? 0, + outerAngle: light.spot?.outerConeAngle ?? Math.PI / 4, + position: [matrix[12], matrix[13], matrix[14]], + rotor: rotor(matrix), + }, + ]; + }); + const cameraNode = nodes.findIndex((node: any) => node.camera !== undefined); + const cameraDefinition = + cameraNode < 0 ? undefined : document.cameras?.[nodes[cameraNode].camera]; + const camera = cameraDefinition + ? { + direction: vector(world(cameraNode), [0, 0, 1]), + fov: cameraDefinition.perspective?.yfov, + } + : undefined; + return { camera, lights, materials, primitives, textures }; } -addEventListener("message", async ({ data }) => { - try { - const result = await load(data.url); - const transfers = result.primitives.flatMap((primitive: any) => - ["positions", "indices", "normals", "tangents", "uvs", "colors"] - .map((name) => primitive[name]?.buffer) - .filter(Boolean), - ); - transfers.push(...result.textures.map((texture: any) => texture.image)); - (postMessage as any)({ request: data.request, result }, transfers); - } catch (error) { - postMessage({ - request: data.request, - error: error instanceof Error ? error.message : "GLTF_IMPORT", - }); - } -}); +if (typeof addEventListener === "function") + addEventListener("message", async ({ data }) => { + try { + const result = await loadGltf(data.url); + const transfers = result.primitives.flatMap((primitive: any) => + ["positions", "indices", "normals", "tangents", "uvs", "uvs2", "colors"] + .map((name) => primitive[name]?.buffer) + .filter(Boolean), + ); + transfers.push(...result.textures.map((texture: any) => texture.image)); + (postMessage as any)({ request: data.request, result }, transfers); + } catch (error) { + postMessage({ + request: data.request, + error: error instanceof Error ? error.message : "GLTF_IMPORT", + }); + } + }); diff --git a/addons/handles/src/materials/PBRMaterial.ts b/addons/handles/src/materials/PBRMaterial.ts index d868472..622c6c6 100644 --- a/addons/handles/src/materials/PBRMaterial.ts +++ b/addons/handles/src/materials/PBRMaterial.ts @@ -1,6 +1,13 @@ import type { Scene } from "../Scene"; import type { Texture } from "./Texture"; +export type PBRTextureInfo = { + texCoord?: number; + offset?: ArrayLike; + scale?: ArrayLike; + rotation?: number; +}; + export type PBRMaterialOptions = { baseColor?: ArrayLike; metallic?: number; @@ -12,9 +19,40 @@ export type PBRMaterialOptions = { metallicRoughnessTexture?: Texture; normalTexture?: Texture; emissiveTexture?: Texture; + occlusionTexture?: Texture; + transmissionTexture?: Texture; + thicknessTexture?: Texture; + clearcoatTexture?: Texture; + iridescenceTexture?: Texture; + iridescenceThicknessTexture?: Texture; + anisotropyTexture?: Texture; + specularTexture?: Texture; + specularColorTexture?: Texture; + textureInfos?: ArrayLike; + flags?: number; + occlusionStrength?: number; + transmission?: number; + ior?: number; + thickness?: number; + attenuationDistance?: number; + attenuationColor?: ArrayLike; + emissiveStrength?: number; + clearcoat?: number; + clearcoatRoughness?: number; + specular?: number; + sheenColor?: ArrayLike; + sheenRoughness?: number; + specularColor?: ArrayLike; + iridescence?: number; + iridescenceIor?: number; + iridescenceThicknessMinimum?: number; + iridescenceThicknessMaximum?: number; + anisotropy?: number; + anisotropyRotation?: number; + specularExtensionColor?: ArrayLike; }; -/** Conventional PBR values and texture IDs stored in two shared SOA rows. */ +/** Conventional PBR values, extensions, texture IDs, and UV transforms. */ export class PBRMaterial { readonly scene: Scene; id = -1; @@ -28,48 +66,152 @@ export class PBRMaterial { options.metallicRoughnessTexture, options.normalTexture, options.emissiveTexture, + options.occlusionTexture, + options.transmissionTexture, + options.thicknessTexture, + options.clearcoatTexture, + options.iridescenceTexture, + options.iridescenceThicknessTexture, + options.anisotropyTexture, + options.specularTexture, + options.specularColorTexture, ].filter((texture): texture is Texture => texture !== undefined); - this.ready = Promise.all(textures.map((texture) => texture.ready)).then(async () => { - const id = await scene.allocateMaterial(); - this.id = id; - const color = Array.from(options.baseColor ?? [1, 1, 1, 1]); - const emissive = Array.from(options.emissive ?? [0, 0, 0]); - if (color.length !== 4 || emissive.length !== 3) throw new TypeError("PBR material vectors"); - scene.array("materials").write(id, [ - ...color, - options.metallic ?? 0, - options.roughness ?? 0.7, - ...emissive, - options.normalScale ?? 1, - options.alphaCutoff ?? 0.5, - 0, - ]); - scene.array("materialTextures").write(id, [ - (options.baseColorTexture?.id ?? -1) + 1, - (options.metallicRoughnessTexture?.id ?? -1) + 1, - (options.normalTexture?.id ?? -1) + 1, - (options.emissiveTexture?.id ?? -1) + 1, - 0, 0, 0, 0, - ]); - return this; - }); + this.ready = Promise.all(textures.map((texture) => texture.ready)).then( + async () => { + const id = await scene.allocateMaterial(); + this.id = id; + const color = Array.from(options.baseColor ?? [1, 1, 1, 1]); + const emissive = Array.from(options.emissive ?? [0, 0, 0]); + const attenuation = Array.from(options.attenuationColor ?? [1, 1, 1]); + const secondary = Array.from( + options.flags && options.flags & 16 + ? (options.specularColor ?? [1, 1, 1]) + : (options.sheenColor ?? [0, 0, 0]), + ); + const specularExtensionColor = Array.from( + options.specularExtensionColor ?? [1, 1, 1], + ); + if ( + color.length !== 4 || + emissive.length !== 3 || + attenuation.length !== 3 || + secondary.length !== 3 || + specularExtensionColor.length !== 3 + ) + throw new TypeError("PBR material vectors"); + scene + .array("materials") + .write(id, [ + ...color, + options.metallic ?? 0, + options.roughness ?? 0.7, + ...emissive, + options.normalScale ?? 1, + options.alphaCutoff ?? 0.5, + options.flags ?? 0, + ]); + scene + .array("materialTextures") + .write(id, [ + (options.baseColorTexture?.id ?? -1) + 1, + (options.metallicRoughnessTexture?.id ?? -1) + 1, + (options.normalTexture?.id ?? -1) + 1, + (options.emissiveTexture?.id ?? -1) + 1, + (options.occlusionTexture?.id ?? -1) + 1, + (options.transmissionTexture?.id ?? -1) + 1, + (options.thicknessTexture?.id ?? -1) + 1, + (options.clearcoatTexture?.id ?? -1) + 1, + (options.iridescenceTexture?.id ?? -1) + 1, + (options.iridescenceThicknessTexture?.id ?? -1) + 1, + (options.anisotropyTexture?.id ?? -1) + 1, + (options.specularTexture?.id ?? -1) + 1, + (options.specularColorTexture?.id ?? -1) + 1, + 0, + 0, + 0, + ]); + scene + .array("materialExtensions") + .write(id, [ + options.transmission ?? 0, + options.ior ?? 1.5, + options.thickness ?? 0, + options.attenuationDistance ?? 0, + ...attenuation, + options.emissiveStrength ?? 1, + options.clearcoat ?? 0, + options.clearcoatRoughness ?? 0, + options.specular ?? 1, + options.occlusionStrength ?? 1, + ...secondary, + options.sheenRoughness ?? 0, + options.iridescence ?? 0, + options.iridescenceIor ?? 1.3, + options.iridescenceThicknessMinimum ?? 100, + options.iridescenceThicknessMaximum ?? 400, + options.anisotropy ?? 0, + options.anisotropyRotation ?? 0, + 0, + 0, + ...specularExtensionColor, + 0, + ]); + const transforms = new Float32Array(104); + for (let slot = 0; slot < 13; slot++) { + const info = options.textureInfos?.[slot]; + const offset = Array.from(info?.offset ?? [0, 0]); + const scale = Array.from(info?.scale ?? [1, 1]); + if (offset.length !== 2 || scale.length !== 2) + throw new TypeError("PBR texture transform"); + const rotation = info?.rotation ?? 0; + transforms.set( + [ + offset[0], + offset[1], + scale[0], + scale[1], + Math.sin(rotation), + Math.cos(rotation), + info?.texCoord ?? 0, + 0, + ], + slot * 8, + ); + } + scene.array("materialTextureInfo").write(id, transforms); + return this; + }, + ); } #values() { - if (this.id < 0) throw new Error("Await material.ready before reading or writing it"); + if (this.id < 0) + throw new Error("Await material.ready before reading or writing it"); return this.scene.array("materials").row(this.id); } - get baseColor() { return this.#values().subarray(0, 4); } + get baseColor() { + return this.#values().subarray(0, 4); + } set baseColor(value: ArrayLike) { if (value.length !== 4) throw new RangeError("baseColor"); this.#values().set(value, 0); } - get metallic() { return this.#values()[4]; } - set metallic(value: number) { this.#values()[4] = value; } - get roughness() { return this.#values()[5]; } - set roughness(value: number) { this.#values()[5] = value; } - get emissive() { return this.#values().subarray(6, 9); } + get metallic() { + return this.#values()[4]; + } + set metallic(value: number) { + this.#values()[4] = value; + } + get roughness() { + return this.#values()[5]; + } + set roughness(value: number) { + this.#values()[5] = value; + } + get emissive() { + return this.#values().subarray(6, 9); + } set emissive(value: ArrayLike) { if (value.length !== 3) throw new RangeError("emissive"); this.#values().set(value, 6); diff --git a/addons/handles/src/materials/Texture.ts b/addons/handles/src/materials/Texture.ts index 7a138c8..dbfe071 100644 --- a/addons/handles/src/materials/Texture.ts +++ b/addons/handles/src/materials/Texture.ts @@ -1,4 +1,5 @@ import type { Scene } from "../Scene"; +import type { GraphSampler } from "../ComputePass"; export type TextureOptions = { source?: string | ImageBitmap; @@ -7,6 +8,7 @@ export type TextureOptions = { usage?: string[]; mipmaps?: boolean; transient?: boolean; + sampler?: Omit; }; let nextTextureName = 1; @@ -52,6 +54,7 @@ export class Texture { ]), ], transient: options.transient ?? false, + sampler: options.sampler, }); this.id = registration.number; await registration.ready; diff --git a/addons/handles/test/core-mock.ts b/addons/handles/test/core-mock.ts new file mode 100644 index 0000000..4deed2f --- /dev/null +++ b/addons/handles/test/core-mock.ts @@ -0,0 +1,220 @@ +type RowFormat = "f32" | "u32" | "i32"; +type RowDescriptor = { + name: string; + rows: number; + stride: number; + format: RowFormat; +}; + +const views = { + f32: Float32Array, + u32: Uint32Array, + i32: Int32Array, +}; + +export class SharedRows { + readonly name: string; + rows: number; + readonly stride: number; + readonly format: RowFormat; + buffer: SharedArrayBuffer; + + constructor({ name, rows, stride, format }: RowDescriptor) { + this.name = name; + this.rows = rows; + this.stride = stride; + this.format = format; + this.buffer = new SharedArrayBuffer(rows * stride); + } + + resize(rows: number) { + if (rows <= this.rows) return this; + const previous = this.view; + this.rows = rows; + this.buffer = new SharedArrayBuffer(rows * this.stride); + this.view.set(previous); + return this; + } + + get view() { + const View = views[this.format]; + return new View(this.buffer); + } + + row(index: number) { + if (!Number.isInteger(index) || index < 0 || index >= this.rows) + throw new RangeError(`ROW_RANGE: ${this.name}[${index}]`); + const width = this.stride / views[this.format].BYTES_PER_ELEMENT; + return this.view.subarray(index * width, (index + 1) * width); + } + + write(index: number, values: ArrayLike) { + const row = this.row(index); + if (values.length !== row.length) throw new RangeError("ROW_WIDTH"); + row.set(values); + return this; + } +} + +function deserialize(serialized: string) { + let offset = 0; + const skip = () => { + while (/\s/u.test(serialized[offset] ?? "")) offset++; + }; + const take = (character: string) => { + skip(); + if (serialized[offset] !== character) + throw new SyntaxError(`Expected ${character} at ${offset}`); + offset++; + }; + const word = () => { + skip(); + const start = offset; + while (offset < serialized.length && !/[\s()]/u.test(serialized[offset])) + offset++; + return serialized.slice(start, offset); + }; + const value = (): any => { + skip(); + if (serialized[offset] === '"') { + const start = offset++; + while (offset < serialized.length) { + if (serialized[offset] === "\\") offset += 2; + else if (serialized[offset++] === '"') break; + } + return JSON.parse(serialized.slice(start, offset)); + } + if (serialized[offset] !== "(") { + const token = word(); + if (token === "true") return true; + if (token === "false") return false; + if (token === "null") return null; + const number = Number(token); + if (!Number.isNaN(number)) return number; + throw new SyntaxError(`Unknown value ${token}`); + } + take("("); + const tag = word(); + if (tag === "array") { + const result = []; + skip(); + while (serialized[offset] !== ")") result.push(value()); + take(")"); + return result; + } + if (tag === "object") { + const result: Record = {}; + skip(); + while (serialized[offset] !== ")") { + take("("); + if (word() !== "field") throw new SyntaxError("Expected field"); + const key = value(); + result[key] = value(); + take(")"); + skip(); + } + take(")"); + return result; + } + if (tag === "yawn-graph") { + if (word() !== "1") throw new SyntaxError("Graph version"); + const result = value(); + take(")"); + return result; + } + throw new SyntaxError(`Unknown list ${tag}`); + }; + return value(); +} + +export class YawnCore { + readonly ready = Promise.resolve(); + readonly graphs: any[] = []; + readonly serializedGraphs: string[] = []; + readonly loadouts: string[] = []; + readonly uploadedTextures: { name: string; mipLevel: number }[] = []; + fps?: number; + disposed = false; + #arrays = new Map(); + #nextObjects = new Map(); + + constructor() { + this.#arrays.set( + "signals", + new SharedRows({ name: "signals", rows: 1, stride: 32, format: "f32" }), + ); + } + + createRows(descriptor: RowDescriptor) { + const current = this.#arrays.get(descriptor.name); + if (current) { + if ( + current.stride !== descriptor.stride || + current.format !== descriptor.format + ) + throw new Error(`ROW_LAYOUT: ${descriptor.name}`); + return Promise.resolve(current.resize(descriptor.rows)); + } + const rows = new SharedRows(descriptor); + this.#arrays.set(descriptor.name, rows); + return Promise.resolve(rows); + } + + createRowsBatch(descriptors: RowDescriptor[]) { + return Promise.all( + descriptors.map((descriptor) => this.createRows(descriptor)), + ); + } + + deleteRows(name: string) { + this.#arrays.delete(name); + return Promise.resolve(); + } + + allocateObject(name: string) { + const id = this.#nextObjects.get(name) ?? 0; + this.#nextObjects.set(name, id + 1); + const rows = this.array(name); + rows.resize(id + 1); + return Promise.resolve(id); + } + + deleteObject() { + return Promise.resolve(); + } + + array(name: string) { + const rows = this.#arrays.get(name); + if (!rows) throw new Error(`UNKNOWN_ARRAY: ${name}`); + return rows; + } + + compileGraph(serialized: string) { + this.serializedGraphs.push(serialized); + this.graphs.push(deserialize(serialized)); + return Promise.resolve(`loadout-${this.graphs.length}`); + } + + switchLoadout(id: string) { + this.loadouts.push(id); + return Promise.resolve(); + } + + setFps(fps: number) { + this.fps = fps; + return Promise.resolve(); + } + + uploadTexture(name: string, _image: unknown, mipLevel = 0) { + this.uploadedTextures.push({ name, mipLevel }); + return Promise.resolve(); + } + + deleteTexture() { + return Promise.resolve(); + } + + dispose() { + this.disposed = true; + } +} diff --git a/addons/handles/test/handles.test.ts b/addons/handles/test/handles.test.ts new file mode 100644 index 0000000..47381b9 --- /dev/null +++ b/addons/handles/test/handles.test.ts @@ -0,0 +1,247 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Mesh } from "../src/Mesh"; +import { Node } from "../src/Node"; +import { ArcRotateCamera } from "../src/camera/ArcRotateCamera"; +import { PointLight } from "../src/lights/PointLight"; +import { PBRMaterial } from "../src/materials/PBRMaterial"; +import type { Texture } from "../src/materials/Texture"; +import { createScene, testCore, triangle } from "./scene-helpers"; + +function close(actual: ArrayLike, expected: ArrayLike) { + assert.equal(actual.length, expected.length); + for (let index = 0; index < actual.length; index++) + assert.ok( + Math.abs(actual[index] - expected[index]) < 1e-5, + `${actual[index]} != ${expected[index]} at ${index}`, + ); +} + +test("PBRMaterial packs factors, extensions, texture pointers, and UV transforms", async () => { + const scene = await createScene(); + const textures = Array.from( + { length: 13 }, + (_, id) => ({ id, ready: Promise.resolve() }) as unknown as Texture, + ); + const material = new PBRMaterial(scene, { + baseColor: [0.1, 0.2, 0.3, 0.4], + metallic: 0.5, + roughness: 0.6, + emissive: [0.7, 0.8, 0.9], + normalScale: 0.75, + alphaCutoff: 0.25, + flags: 6, + baseColorTexture: textures[0], + metallicRoughnessTexture: textures[1], + normalTexture: textures[2], + emissiveTexture: textures[3], + occlusionTexture: textures[4], + transmissionTexture: textures[5], + thicknessTexture: textures[6], + clearcoatTexture: textures[7], + iridescenceTexture: textures[8], + iridescenceThicknessTexture: textures[9], + anisotropyTexture: textures[10], + specularTexture: textures[11], + specularColorTexture: textures[12], + transmission: 0.8, + ior: 1.6, + thickness: 0.4, + attenuationDistance: 3, + attenuationColor: [0.1, 0.2, 0.3], + emissiveStrength: 4, + clearcoat: 0.5, + clearcoatRoughness: 0.6, + specular: 0.7, + occlusionStrength: 0.8, + sheenColor: [0.9, 0.1, 0.2], + sheenRoughness: 0.3, + iridescence: 0.4, + iridescenceIor: 1.4, + iridescenceThicknessMinimum: 120, + iridescenceThicknessMaximum: 380, + anisotropy: 0.5, + anisotropyRotation: 0.6, + specularExtensionColor: [0.7, 0.8, 0.9], + textureInfos: [ + { + offset: [0.1, 0.2], + scale: [0.3, 0.4], + rotation: 0.5, + texCoord: 1, + }, + ], + }); + await material.ready; + + close( + scene.array("materials").row(material.id), + [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.75, 0.25, 6], + ); + assert.deepEqual( + Array.from(scene.array("materialTextures").row(material.id)), + [...Array.from({ length: 13 }, (_, index) => index + 1), 0, 0, 0], + ); + close( + scene.array("materialExtensions").row(material.id), + [ + 0.8, 1.6, 0.4, 3, 0.1, 0.2, 0.3, 4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.1, 0.2, + 0.3, 0.4, 1.4, 120, 380, 0.5, 0.6, 0, 0, 0.7, 0.8, 0.9, 0, + ], + ); + const transforms = scene.array("materialTextureInfo").row(material.id); + close(transforms.subarray(0, 8), [ + 0.1, + 0.2, + 0.3, + 0.4, + Math.sin(0.5), + Math.cos(0.5), + 1, + 0, + ]); + close(transforms.subarray(8, 16), [0, 0, 1, 1, 0, 1, 0, 0]); +}); + +test("hot material setters write shared rows without rebuilding the graph", async () => { + const scene = await createScene(); + const material = new PBRMaterial(scene); + await material.ready; + const core = testCore(scene); + const compiled = core.graphs.length; + core.array("signals").row(0).fill(0); + + material.baseColor = [0.2, 0.3, 0.4, 0.5]; + material.metallic = 0.6; + material.roughness = 0.7; + material.emissive = [0.8, 0.9, 1]; + + close( + scene.array("materials").row(material.id), + [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1, 0.5, 0], + ); + assert.equal(core.array("signals").row(0)[5], 1); + assert.equal(core.graphs.length, compiled); +}); + +test("Mesh packs geometry and bounds, then clones with copy-on-write", async () => { + const scene = await createScene(); + const firstMaterial = new PBRMaterial(scene); + const secondMaterial = new PBRMaterial(scene, { baseColor: [1, 0, 0, 1] }); + await Promise.all([firstMaterial.ready, secondMaterial.ready]); + const mesh = new Mesh(scene, { + material: firstMaterial, + vertexData: triangle, + }); + await mesh.ready; + + assert.equal(mesh.vertexCount, 3); + assert.equal(mesh.indexCount, 3); + close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions); + close(scene.array("bounds").row(mesh.id), [-1, -1, 0, 0, 1, 1, 0, 0]); + + const clone = mesh.clone({ position: [2, 0, 0] }); + await clone.ready; + assert.equal(clone.geometryId, mesh.geometryId); + await clone.setVertexData("positions", [0, 0, 0, 2, 0, 0, 0, 3, 0]); + assert.notEqual(clone.geometryId, mesh.geometryId); + close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions); + close( + scene.geometryData(clone.geometryId, "positions")!, + [0, 0, 0, 2, 0, 0, 0, 3, 0], + ); + + clone.material = secondMaterial; + await clone.setMaterialForFaces(firstMaterial, 0); + assert.equal(clone.materialId, secondMaterial.id); + assert.equal(clone.faceMaterials.get(0), firstMaterial.id); + assert.equal( + scene.array(`mesh.${clone.id}.faceMaterials`).row(0)[0], + firstMaterial.id + 1, + ); +}); + +test("Node, ArcRotateCamera, and light handles update their shared rows", async () => { + const scene = await createScene(); + const target = new Node(scene, { position: [1, 2, 3], scale: [2, 3, 4] }); + await target.ready; + target.translate([1, -1, 2]).rotateZ(Math.PI); + close(target.position, [2, 1, 5]); + close(target.scale, [2, 3, 4]); + close(target.rotor, [0, 0, 1, 0]); + + const child = new Node(scene, { parent: target }); + await child.ready; + assert.equal(child.parentId, target.id); + child.enabled = false; + assert.equal(scene.array("nodes").row(child.id)[0], 0); + + const camera = new ArcRotateCamera(scene, { + target, + alpha: 0, + beta: Math.PI / 2, + radius: 5, + }); + await camera.ready; + close(camera.position, [2, 1, 10]); + assert.equal(scene.array("cameraMatrices").row(camera.cameraId)[19], 1); + camera.radius = 3; + close(camera.position, [2, 1, 8]); + + const light = new PointLight(scene, { + position: [4, 5, 6], + color: [0.2, 0.4, 0.6], + intensity: 7, + range: 8, + }); + await light.ready; + close( + scene.array("pointLights").row(light.id), + [0.2, 0.4, 0.6, 7, 8, 1, 0, 0], + ); + light.intensity = 9; + light.range = 10; + close(scene.array("pointLights").row(light.id).subarray(3, 5), [9, 10]); +}); + +test("ArcRotateCamera pans one projected world pixel per pointer pixel", async () => { + const listeners = new Map void>>(); + const element = { + clientWidth: 400, + clientHeight: 200, + addEventListener(type: string, listener: (event: any) => void) { + const registered = listeners.get(type) ?? new Set(); + registered.add(listener); + listeners.set(type, registered); + }, + removeEventListener(type: string, listener: (event: any) => void) { + listeners.get(type)?.delete(listener); + }, + setPointerCapture() {}, + } as unknown as HTMLElement; + const dispatch = (type: string, event: Record) => { + for (const listener of listeners.get(type) ?? []) + listener({ preventDefault() {}, ...event }); + }; + const scene = await createScene(); + const camera = new ArcRotateCamera(scene, { + alpha: 0, + beta: Math.PI / 2, + radius: 10, + fov: Math.PI / 2, + aspect: 2, + controls: { element }, + }); + await camera.ready; + + dispatch("pointerdown", { + button: 2, + clientX: 100, + clientY: 100, + pointerId: 1, + }); + dispatch("pointermove", { clientX: 120, clientY: 110 }); + + close(camera.position, [-2, 1, 10]); + camera.detachControls(); +}); diff --git a/addons/handles/test/import-gltf.test.ts b/addons/handles/test/import-gltf.test.ts new file mode 100644 index 0000000..e0ec9fe --- /dev/null +++ b/addons/handles/test/import-gltf.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importGltf } from "../src/importers/gltf"; +import { createScene, latestGraph, testCore } from "./scene-helpers"; + +function close(actual: ArrayLike, expected: ArrayLike) { + assert.equal(actual.length, expected.length); + for (let index = 0; index < actual.length; index++) + assert.ok(Math.abs(actual[index] - expected[index]) < 1e-5); +} + +test("importGltf hydrates worker output into one batched scene update", async () => { + const previousWorker = globalThis.Worker; + const previousImageBitmap = globalThis.ImageBitmap; + const previousCreateImageBitmap = globalThis.createImageBitmap; + class MockImageBitmap { + readonly width = 1; + readonly height = 1; + } + const workerResult = { + camera: { direction: [0, 0, 1], fov: 1.1 }, + textures: [ + { + image: new MockImageBitmap(), + sampler: { + magFilter: "linear", + minFilter: "linear", + mipmapFilter: "linear", + addressModeU: "repeat", + addressModeV: "repeat", + }, + }, + ], + materials: [ + { + baseColor: [0.2, 0.4, 0.6, 1], + metallic: 0.3, + roughness: 0.7, + flags: 0, + baseColorTexture: 0, + metallicRoughnessTexture: -1, + normalTexture: -1, + emissiveTexture: -1, + occlusionTexture: -1, + transmissionTexture: -1, + thicknessTexture: -1, + clearcoatTexture: -1, + iridescenceTexture: -1, + iridescenceThicknessTexture: -1, + anisotropyTexture: -1, + specularTexture: -1, + specularColorTexture: -1, + }, + ], + primitives: [ + { + positions: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]), + uvs: new Float32Array([0, 0, 1, 0, 0, 1]), + indices: new Uint32Array([0, 1, 2]), + material: 0, + position: [2, 3, 4], + rotor: [0, 0, 0, 1], + scale: [1, 1, 1], + }, + ], + lights: [ + { + type: "point", + color: [1, 0.5, 0.25], + intensity: 6, + range: 12, + position: [4, 5, 6], + }, + ], + }; + class MockWorker { + #listeners = new Map void>(); + + addEventListener(type: string, listener: (event: any) => void) { + this.#listeners.set(type, listener); + } + + postMessage(message: { request: number }) { + queueMicrotask(() => + this.#listeners.get("message")?.({ + data: { request: message.request, result: workerResult }, + }), + ); + } + } + Object.defineProperties(globalThis, { + Worker: { configurable: true, value: MockWorker }, + ImageBitmap: { configurable: true, value: MockImageBitmap }, + createImageBitmap: { + configurable: true, + value: async () => new MockImageBitmap(), + }, + }); + + try { + const scene = await createScene(); + const core = testCore(scene); + const compiled = core.graphs.length; + const meshes = await importGltf(scene, "https://example.test/model.glb"); + + assert.equal(meshes.length, 1); + assert.deepEqual(meshes.camera, workerResult.camera); + assert.deepEqual(meshes[0].position.toArray(), [2, 3, 4]); + assert.deepEqual( + Array.from(scene.geometryData(meshes[0].geometryId, "indices")!), + [0, 1, 2], + ); + close( + scene.array("materials").row(meshes[0].materialId).slice(0, 6), + [0.2, 0.4, 0.6, 1, 0.3, 0.7], + ); + assert.equal( + scene.array("materialTextures").row(meshes[0].materialId)[0], + 1, + ); + assert.deepEqual( + Array.from(scene.array("pointLights").row(1)), + [1, 0.5, 0.25, 6, 12, 1, 0, 0], + ); + const texture = latestGraph(scene).resources.textures.find( + (resource: any) => resource.id.startsWith("texture-"), + ); + assert.equal(texture.format, "rgba8unorm-srgb"); + assert.equal(core.uploadedTextures.length, 1); + assert.equal(core.graphs.length - compiled, 1); + } finally { + for (const [name, previous] of [ + ["Worker", previousWorker], + ["ImageBitmap", previousImageBitmap], + ["createImageBitmap", previousCreateImageBitmap], + ] as const) + if (previous === undefined) delete (globalThis as any)[name]; + else + Object.defineProperty(globalThis, name, { + configurable: true, + value: previous, + }); + } +}); diff --git a/addons/handles/test/importer.test.ts b/addons/handles/test/importer.test.ts new file mode 100644 index 0000000..50aefc2 --- /dev/null +++ b/addons/handles/test/importer.test.ts @@ -0,0 +1,359 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadGltf } from "../src/importers/worker"; + +function close(actual: ArrayLike, expected: ArrayLike) { + assert.equal(actual.length, expected.length); + for (let index = 0; index < actual.length; index++) + assert.ok( + Math.abs(actual[index] - expected[index]) < 1e-5, + `${actual[index]} != ${expected[index]} at ${index}`, + ); +} + +function dataUrl(type: string, value: Uint8Array | string) { + return `data:${type};base64,${Buffer.from(value).toString("base64")}`; +} + +function binaryBuilder() { + let bytes = new Uint8Array(); + return { + append(source: ArrayBufferView, alignment: number) { + const input = new Uint8Array( + source.buffer, + source.byteOffset, + source.byteLength, + ); + const offset = Math.ceil(bytes.length / alignment) * alignment; + const next = new Uint8Array(offset + input.length); + next.set(bytes); + next.set(input, offset); + bytes = next; + return { byteOffset: offset, byteLength: input.length }; + }, + get bytes() { + return bytes; + }, + }; +} + +function glb(document: object, binary: Uint8Array) { + const json = new TextEncoder().encode(JSON.stringify(document)); + const jsonLength = Math.ceil(json.length / 4) * 4; + const binaryLength = Math.ceil(binary.length / 4) * 4; + const result = new Uint8Array(12 + 8 + jsonLength + 8 + binaryLength); + const view = new DataView(result.buffer); + view.setUint32(0, 0x46546c67, true); + view.setUint32(4, 2, true); + view.setUint32(8, result.length, true); + view.setUint32(12, jsonLength, true); + view.setUint32(16, 0x4e4f534a, true); + result.fill(0x20, 20, 20 + jsonLength); + result.set(json, 20); + const binaryHeader = 20 + jsonLength; + view.setUint32(binaryHeader, binaryLength, true); + view.setUint32(binaryHeader + 4, 0x004e4942, true); + result.set(binary, binaryHeader + 8); + return result; +} + +test("JSON glTF parsing covers accessors, transforms, materials, lights, and cameras", async () => { + const binary = binaryBuilder(); + const positions = binary.append( + new Float32Array([0, 0, 0, 99, 1, 0, 0, 99, 0, 1, 0, 99]), + 4, + ); + const uvs = binary.append(new Uint16Array([0, 0, 65_535, 0, 0, 65_535]), 2); + const colors = binary.append( + new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255]), + 1, + ); + const indices = binary.append(new Uint16Array([0, 1, 2]), 2); + const sparseIndices = binary.append(new Uint8Array([1]), 1); + const sparsePositions = binary.append(new Float32Array([2, 0, 0]), 4); + const texture = { index: 0 }; + const document = { + asset: { version: "2.0" }, + extensions: { + KHR_lights_punctual: { + lights: [ + { + type: "spot", + color: [0.2, 0.4, 0.6], + intensity: 7, + range: 8, + spot: { innerConeAngle: 0.1, outerConeAngle: 0.5 }, + }, + ], + }, + }, + buffers: [ + { + byteLength: binary.bytes.length, + uri: dataUrl("application/octet-stream", binary.bytes), + }, + ], + bufferViews: [ + { buffer: 0, ...positions, byteStride: 16 }, + { buffer: 0, ...uvs }, + { buffer: 0, ...colors }, + { buffer: 0, ...indices }, + { buffer: 0, ...sparseIndices }, + { buffer: 0, ...sparsePositions }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: "VEC3", + sparse: { + count: 1, + indices: { bufferView: 4, componentType: 5121 }, + values: { bufferView: 5 }, + }, + }, + { + bufferView: 1, + componentType: 5123, + normalized: true, + count: 3, + type: "VEC2", + }, + { + bufferView: 2, + componentType: 5121, + normalized: true, + count: 3, + type: "VEC3", + }, + { + bufferView: 3, + componentType: 5123, + count: 3, + type: "SCALAR", + }, + ], + images: [ + { + uri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + }, + ], + samplers: [ + { magFilter: 9728, minFilter: 9984, wrapS: 33071, wrapT: 33648 }, + ], + textures: [{ source: 0, sampler: 0 }], + materials: [ + { + alphaMode: "BLEND", + doubleSided: true, + normalTexture: { ...texture, scale: 0.75 }, + emissiveTexture: texture, + emissiveFactor: [0.4, 0.5, 0.6], + occlusionTexture: { ...texture, strength: 0.7 }, + extensions: { + KHR_materials_unlit: {}, + KHR_materials_pbrSpecularGlossiness: { + diffuseFactor: [0.1, 0.2, 0.3, 0.4], + specularFactor: [0.5, 0.6, 0.7], + glossinessFactor: 0.75, + diffuseTexture: { + ...texture, + extensions: { + KHR_texture_transform: { + offset: [0.1, 0.2], + scale: [0.3, 0.4], + rotation: 0.5, + texCoord: 1, + }, + }, + }, + specularGlossinessTexture: texture, + }, + KHR_materials_transmission: { + transmissionFactor: 0.8, + transmissionTexture: texture, + }, + KHR_materials_volume: { + thicknessFactor: 0.9, + thicknessTexture: texture, + attenuationDistance: 3, + attenuationColor: [0.2, 0.3, 0.4], + }, + KHR_materials_clearcoat: { + clearcoatFactor: 0.45, + clearcoatRoughnessFactor: 0.25, + clearcoatTexture: texture, + }, + KHR_materials_sheen: { + sheenColorFactor: [0.3, 0.2, 0.1], + sheenRoughnessFactor: 0.35, + }, + KHR_materials_specular: { + specularFactor: 0.55, + specularColorFactor: [0.7, 0.8, 0.9], + specularTexture: texture, + specularColorTexture: texture, + }, + KHR_materials_iridescence: { + iridescenceFactor: 0.65, + iridescenceIor: 1.4, + iridescenceThicknessMinimum: 120, + iridescenceThicknessMaximum: 360, + iridescenceTexture: texture, + iridescenceThicknessTexture: texture, + }, + KHR_materials_anisotropy: { + anisotropyStrength: 0.75, + anisotropyRotation: 0.15, + anisotropyTexture: texture, + }, + KHR_materials_emissive_strength: { emissiveStrength: 4 }, + KHR_materials_ior: { ior: 1.6 }, + }, + }, + ], + meshes: [ + { + primitives: [ + { + attributes: { + POSITION: 0, + TEXCOORD_0: 1, + TEXCOORD_1: 1, + COLOR_0: 2, + }, + indices: 3, + material: 0, + }, + ], + }, + ], + cameras: [{ type: "perspective", perspective: { yfov: 1.1 } }], + nodes: [ + { + mesh: 0, + camera: 0, + translation: [5, 0, 0], + scale: [-1, 1, 1], + extensions: { KHR_lights_punctual: { light: 0 } }, + }, + ], + }; + const previousCreateImageBitmap = globalThis.createImageBitmap; + Object.defineProperty(globalThis, "createImageBitmap", { + configurable: true, + value: async () => ({ width: 1, height: 1 }), + }); + try { + const result = await loadGltf( + dataUrl("model/gltf+json", JSON.stringify(document)), + ); + assert.equal(result.primitives.length, 1); + const primitive = result.primitives[0]; + close(primitive.positions, [5, 0, 0, 3, 0, 0, 5, 1, 0]); + assert.deepEqual(Array.from(primitive.indices), [0, 2, 1]); + close(primitive.normals, [0, 0, 1, 0, 0, 1, 0, 0, 1]); + close(primitive.uvs, [0, 0, 1, 0, 0, 1]); + close(primitive.uvs2, [0, 0, 1, 0, 0, 1]); + close(primitive.colors, [1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1]); + assert.equal(primitive.tangents.length, 12); + assert.equal(Array.from(primitive.tangents).every(Number.isFinite), true); + + const material = result.materials[0]; + close(material.baseColor, [0.1, 0.2, 0.3, 0.4]); + assert.equal(material.metallic, 0); + assert.equal(material.roughness, 0.25); + assert.equal(material.flags, 30); + assert.equal(material.transmission, 0.8); + assert.equal(material.ior, 1.6); + assert.equal(material.thickness, 0.9); + assert.equal(material.attenuationDistance, 3); + close(material.attenuationColor, [0.2, 0.3, 0.4]); + assert.equal(material.emissiveStrength, 4); + assert.equal(material.clearcoat, 0.45); + assert.equal(material.clearcoatRoughness, 0.25); + assert.equal(material.specular, 0.55); + close(material.specularColor, [0.5, 0.6, 0.7]); + close(material.specularExtensionColor, [0.7, 0.8, 0.9]); + assert.equal(material.iridescence, 0.65); + assert.equal(material.anisotropy, 0.75); + assert.deepEqual( + [ + material.baseColorTexture, + material.metallicRoughnessTexture, + material.normalTexture, + material.emissiveTexture, + material.occlusionTexture, + material.transmissionTexture, + material.thicknessTexture, + material.clearcoatTexture, + material.iridescenceTexture, + material.iridescenceThicknessTexture, + material.anisotropyTexture, + material.specularTexture, + material.specularColorTexture, + ], + Array(13).fill(0), + ); + assert.deepEqual(material.textureInfos[0], { + texture: 0, + texCoord: 1, + offset: [0.1, 0.2], + scale: [0.3, 0.4], + rotation: 0.5, + }); + + assert.equal(result.textures.length, 1); + assert.deepEqual(result.textures[0].sampler, { + magFilter: "nearest", + minFilter: "nearest", + mipmapFilter: "nearest", + addressModeU: "clamp-to-edge", + addressModeV: "mirror-repeat", + }); + assert.deepEqual(result.camera, { direction: [0, 0, 1], fov: 1.1 }); + assert.equal(result.lights.length, 1); + const { rotor, ...light } = result.lights[0]; + assert.deepEqual(light, { + type: "spot", + color: [0.2, 0.4, 0.6], + intensity: 7, + range: 8, + innerAngle: 0.1, + outerAngle: 0.5, + position: [5, 0, 0], + }); + close(rotor, [0, 0, 0, 1]); + } finally { + if (previousCreateImageBitmap) + Object.defineProperty(globalThis, "createImageBitmap", { + configurable: true, + value: previousCreateImageBitmap, + }); + else delete (globalThis as any).createImageBitmap; + } +}); + +test("binary GLB parsing consumes JSON and BIN chunks", async () => { + const binary = new Uint8Array( + new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer, + ); + const document = { + asset: { version: "2.0" }, + buffers: [{ byteLength: binary.length }], + bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: binary.length }], + accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], + }; + + const result = await loadGltf( + dataUrl("model/gltf-binary", glb(document, binary)), + ); + assert.equal(result.primitives.length, 1); + close(result.primitives[0].positions, [0, 0, 0, 1, 0, 0, 0, 1, 0]); + close(result.primitives[0].normals, [0, 0, 1, 0, 0, 1, 0, 0, 1]); + assert.deepEqual(Array.from(result.primitives[0].indices), [0, 1, 2]); + assert.deepEqual(result.materials, []); + assert.deepEqual(result.textures, []); +}); diff --git a/addons/handles/test/run.mjs b/addons/handles/test/run.mjs new file mode 100644 index 0000000..92155c2 --- /dev/null +++ b/addons/handles/test/run.mjs @@ -0,0 +1,46 @@ +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { build } from "esbuild"; + +const directory = fileURLToPath(new URL(".", import.meta.url)); +const mock = join(directory, "core-mock.ts"); +const output = await mkdtemp(join(tmpdir(), "yawn-handles-tests-")); + +try { + const entries = (await readdir(directory)) + .filter((name) => name.endsWith(".test.ts")) + .sort() + .map((name) => join(directory, name)); + await build({ + entryPoints: entries, + outdir: output, + entryNames: "[name]", + outExtension: { ".js": ".mjs" }, + bundle: true, + format: "esm", + platform: "node", + target: "node20", + sourcemap: "inline", + plugins: [ + { + name: "mock-yawn-core", + setup(build) { + build.onResolve({ filter: /^@yawn\/core$/ }, () => ({ path: mock })); + }, + }, + ], + }); + const tests = entries.map((entry) => + join(output, `${basename(entry, ".ts")}.mjs`), + ); + const result = spawnSync(process.execPath, ["--test", ...tests], { + stdio: "inherit", + }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} finally { + await rm(output, { force: true, recursive: true }); +} diff --git a/addons/handles/test/scene-graph.test.ts b/addons/handles/test/scene-graph.test.ts new file mode 100644 index 0000000..44ed212 --- /dev/null +++ b/addons/handles/test/scene-graph.test.ts @@ -0,0 +1,275 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Mesh } from "../src/Mesh"; +import { FXAA } from "../src/PostProcesses/FXAA"; +import { PBRMaterial } from "../src/materials/PBRMaterial"; +import { Texture } from "../src/materials/Texture"; +import { + createScene, + detailedMesh, + forwardPasses, + latestGraph, + pbrPipelines, + renderPipeline, + testCore, + triangle, +} from "./scene-helpers"; + +if (typeof globalThis.ImageBitmap === "undefined") + Object.defineProperty(globalThis, "ImageBitmap", { + value: class ImageBitmap {}, + }); + +test("empty scenes clear without drawing placeholder geometry", async () => { + const scene = await createScene(); + const emptyPass = latestGraph(scene).passes.find( + (pass: any) => pass.id === "forward-empty", + ); + + assert.equal(emptyPass.draw.vertices, 0); +}); + +test("opaque PBR graph fuses the background and compacts sparse lights", async () => { + const scene = await createScene(); + const material = new PBRMaterial(scene); + await material.ready; + const mesh = detailedMesh(scene, material); + await mesh.ready; + + const graph = latestGraph(scene); + const pipeline = pbrPipelines(graph)[0]; + const forward = forwardPasses(graph)[0]; + const present = renderPipeline(graph, "present"); + const clusters = graph.pipelines.compute.find( + (candidate: any) => candidate.id === "cluster-lights", + ); + + assert.equal(pipeline.primitive.frontFace, "ccw"); + assert.equal( + graph.passes.some((pass: any) => pass.id === "studio-background"), + false, + ); + assert.match(present.code, /fn studioBackground\(/u); + assert.deepEqual(forward.color[0].clear, [0, 0, 0, 0]); + assert.match(pipeline.code, /@interpolate\(flat\) material: u32/u); + assert.match(pipeline.code, /materials\[input\.material \* 3u\]/u); + + assert.match(clusters.code, /clusters\[11u \+ pointCount\] = i/u); + assert.match(clusters.code, /clusters\[512u \+ spotCount\] = i/u); + assert.match(pipeline.code, /let pointCount = clusters\[10\]/u); + assert.match(pipeline.code, /pointCount <= 500u/u); + assert.match(pipeline.code, /arrayLength\(&pointLights\) \/ 2u/u); + assert.match(pipeline.code, /let spotCount = clusters\[511\]/u); + assert.match(pipeline.code, /spotCount <= 500u/u); +}); + +test("materials with identical shader features share one pipeline", async () => { + const scene = await createScene(); + const firstMaterial = new PBRMaterial(scene, { roughness: 0.2 }); + const secondMaterial = new PBRMaterial(scene, { roughness: 0.8 }); + await Promise.all([firstMaterial.ready, secondMaterial.ready]); + const firstMesh = detailedMesh(scene, firstMaterial); + const secondMesh = detailedMesh(scene, secondMaterial); + await Promise.all([firstMesh.ready, secondMesh.ready]); + + const graph = latestGraph(scene); + const passes = forwardPasses(graph); + assert.equal(pbrPipelines(graph).length, 1); + assert.equal(passes.length, 2); + assert.deepEqual( + passes.map((pass: any) => pass.draw.firstInstance & 0xffff), + [firstMesh.id, secondMesh.id], + ); + assert.deepEqual( + passes.map((pass: any) => (pass.draw.firstInstance >>> 16) - 1), + [firstMaterial.id, secondMaterial.id], + ); +}); + +test("transmission renders directly into a composited layer with five taps", async () => { + const scene = await createScene(); + const material = new PBRMaterial(scene, { + transmission: 0.9, + thickness: 0.25, + attenuationDistance: 2, + }); + await material.ready; + const mesh = detailedMesh(scene, material); + await mesh.ready; + + const graph = latestGraph(scene); + const pipeline = pbrPipelines(graph)[0]; + const present = renderPipeline(graph, "present"); + const presentPass = graph.passes.find((pass: any) => pass.id === "present"); + + assert.equal( + graph.passes.some((pass: any) => pass.id === "studio-background"), + true, + ); + assert.equal( + graph.passes.some((pass: any) => pass.id === "copy-scene-color"), + false, + ); + assert.equal( + presentPass.bindings.some( + (binding: any) => binding.resource === "scene-color", + ), + true, + ); + assert.match(present.code, /textureSample\(scene, sourceSampler/u); + assert.equal( + pipeline.code.match(/textureSampleLevel\(sceneColor/g)?.length, + 5, + ); + assert.match(pipeline.code, /transmitted \*= pow/u); +}); + +test("post-processing keeps the conservative transmission copy", async () => { + const scene = await createScene(); + const material = new PBRMaterial(scene, { transmission: 1 }); + await material.ready; + const mesh = detailedMesh(scene, material); + await mesh.ready; + const effect = new FXAA(scene); + await effect.ready; + + const graph = latestGraph(scene); + const presentPass = graph.passes.find((pass: any) => pass.id === "present"); + assert.equal( + graph.passes.some((pass: any) => pass.id === "copy-scene-color"), + true, + ); + assert.equal( + presentPass.bindings.some( + (binding: any) => binding.resource === "scene-color", + ), + false, + ); + assert.equal( + graph.passes.some((pass: any) => pass.pipeline.startsWith("post-fxaa-")), + true, + ); +}); + +test("PBR generation eliminates inactive features and specializes active ones", async () => { + const scene = await createScene(); + const textures = Array.from( + { length: 13 }, + () => new Texture(scene, { size: [1, 1, 1] }), + ); + await Promise.all(textures.map((texture) => texture.ready)); + const textureOptions = { + baseColorTexture: textures[0], + metallicRoughnessTexture: textures[1], + normalTexture: textures[2], + emissiveTexture: textures[3], + occlusionTexture: textures[4], + transmissionTexture: textures[5], + thicknessTexture: textures[6], + clearcoatTexture: textures[7], + iridescenceTexture: textures[8], + iridescenceThicknessTexture: textures[9], + anisotropyTexture: textures[10], + specularTexture: textures[11], + specularColorTexture: textures[12], + }; + const active = new PBRMaterial(scene, { + ...textureOptions, + transmission: 1, + thickness: 0.5, + attenuationDistance: 2, + clearcoat: 0.7, + sheenColor: [0.2, 0.3, 0.4], + iridescence: 0.8, + anisotropy: 0.6, + textureInfos: Array.from({ length: 13 }, (_, slot) => ({ + texCoord: slot === 1 ? 1 : 0, + })), + }); + const unlit = new PBRMaterial(scene, { ...textureOptions, flags: 8 }); + await Promise.all([active.ready, unlit.ready]); + const activeMesh = detailedMesh(scene, active); + const unlitMesh = detailedMesh(scene, unlit); + await Promise.all([activeMesh.ready, unlitMesh.ready]); + + const pipelines = pbrPipelines(latestGraph(scene)); + const activeCode = pipelines.find((pipeline: any) => + pipeline.code.includes("transmissionAmount"), + ).code; + const unlitCode = pipelines.find((pipeline: any) => + pipeline.code.includes("color = base.rgb + emissive"), + ).code; + + for (const binding of [ + "baseTexture", + "materialTexture", + "normalTexture", + "emissiveTexture", + "occlusionTexture", + "transmissionTexture", + "thicknessTexture", + "clearcoatTexture", + "iridescenceTexture", + "iridescenceThicknessTexture", + "anisotropyTexture", + "specularTexture", + "specularColorTexture", + ]) + assert.match(activeCode, new RegExp(`var ${binding}: texture_2d`)); + assert.match( + activeCode, + /textureSample\(baseTexture, materialSampler, input\.uv\)/u, + ); + assert.match( + activeCode, + /textureSample\(materialTexture, materialSampler, input\.uv2\)/u, + ); + assert.match(activeCode, /let anisotropyAmount/u); + assert.match(activeCode, /let iridescenceAmount/u); + assert.match(activeCode, /let clearcoatFresnel/u); + assert.match(activeCode, /let sheen =/u); + assert.match(unlitCode, /var baseTexture: texture_2d/u); + assert.match(unlitCode, /var emissiveTexture: texture_2d/u); + assert.doesNotMatch(unlitCode, /var normalTexture: texture_2d/u); + assert.doesNotMatch(unlitCode, /var clearcoatTexture: texture_2d/u); + assert.doesNotMatch(unlitCode, /let clearcoatFresnel/u); +}); + +test("alpha blending uses source-over and disables depth writes", async () => { + const scene = await createScene(); + const material = new PBRMaterial(scene, { flags: 2 }); + await material.ready; + const mesh = detailedMesh(scene, material); + await mesh.ready; + + const pipeline = pbrPipelines(latestGraph(scene))[0]; + assert.deepEqual(pipeline.fragment.targets[0].blend, { + color: { + srcFactor: "src-alpha", + dstFactor: "one-minus-src-alpha", + operation: "add", + }, + alpha: { + srcFactor: "one", + dstFactor: "one-minus-src-alpha", + operation: "add", + }, + }); + assert.equal(pipeline.depthStencil.depth_write_enabled, false); +}); + +test("batchGraphUpdates compiles once for multiple mesh registrations", async () => { + const scene = await createScene(); + const material = new PBRMaterial(scene); + await material.ready; + const before = testCore(scene).graphs.length; + + await scene.batchGraphUpdates(async () => { + const first = new Mesh(scene, { material, vertexData: triangle }); + const second = new Mesh(scene, { material, vertexData: triangle }); + await Promise.all([first.ready, second.ready]); + }); + + assert.equal(testCore(scene).graphs.length - before, 1); + assert.equal(forwardPasses(latestGraph(scene)).length, 2); +}); diff --git a/addons/handles/test/scene-helpers.ts b/addons/handles/test/scene-helpers.ts new file mode 100644 index 0000000..b1e23a5 --- /dev/null +++ b/addons/handles/test/scene-helpers.ts @@ -0,0 +1,53 @@ +import { Mesh } from "../src/Mesh"; +import { Scene } from "../src/Scene"; +import type { PBRMaterial } from "../src/materials/PBRMaterial"; +import type { YawnCore } from "./core-mock"; + +export const triangle = { + positions: [-1, -1, 0, 1, -1, 0, 0, 1, 0], + normals: [0, 0, 1, 0, 0, 1, 0, 0, 1], + tangents: [1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1], + uvs: [0, 0, 1, 0, 0.5, 1], + uvs2: [0.1, 0.1, 0.9, 0.1, 0.5, 0.9], + colors: [1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1], + indices: [0, 1, 2], +}; + +export async function createScene() { + const scene = new Scene({} as HTMLCanvasElement); + await scene.ready; + return scene; +} + +export function testCore(scene: Scene) { + return scene.core as unknown as YawnCore; +} + +export function latestGraph(scene: Scene): any { + return testCore(scene).graphs.at(-1); +} + +export function renderPipeline(graph: any, id: string) { + return graph.pipelines.render.find((pipeline: any) => pipeline.id === id); +} + +export function pbrPipelines(graph: any) { + return graph.pipelines.render.filter((pipeline: any) => + pipeline.id.startsWith("forward-pbr-"), + ); +} + +export function forwardPasses(graph: any) { + return graph.passes.filter( + (pass: any) => + pass.type === "render" && pass.pipeline.startsWith("forward-"), + ); +} + +export function detailedMesh( + scene: Scene, + material: PBRMaterial, + vertexData: Record> = triangle, +) { + return new Mesh(scene, { material, vertexData }); +} diff --git a/addons/handles/test/screenshots/README.md b/addons/handles/test/screenshots/README.md new file mode 100644 index 0000000..aaeddd8 --- /dev/null +++ b/addons/handles/test/screenshots/README.md @@ -0,0 +1,18 @@ +# glTF screenshot tests + +`npm run test:handles:screenshots` renders the viewer's empty state and every +glTF-Binary model in the pinned Khronos glTF Sample Models revision. The suite +uses headed Chromium on a private Xvfb display and SwiftShader WebGPU at a fixed +1000×720 viewport. It checks the viewer state, browser diagnostics, and a +250×180 downsample against each reviewed reference image. + +The first run downloads roughly 215 MB of GLBs into +`~/.cache/yawn/gltf-sample-models`. Set `YAWN_GLTF_FIXTURES` to use another +cache directory, or `YAWN_SCREENSHOT_MODELS=Box,BoomBox` to run a subset. +Failed full-size captures and visual diffs are written to `output/`. + +To intentionally replace references after reviewing full-size renders, run: + +```sh +UPDATE_SCREENSHOTS=1 npm run test:handles:screenshots +``` diff --git a/addons/handles/test/screenshots/baselines/2CylinderEngine.png b/addons/handles/test/screenshots/baselines/2CylinderEngine.png new file mode 100644 index 0000000..44733fb Binary files /dev/null and b/addons/handles/test/screenshots/baselines/2CylinderEngine.png differ diff --git a/addons/handles/test/screenshots/baselines/AlphaBlendModeTest.png b/addons/handles/test/screenshots/baselines/AlphaBlendModeTest.png new file mode 100644 index 0000000..d371535 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/AlphaBlendModeTest.png differ diff --git a/addons/handles/test/screenshots/baselines/AnimatedMorphCube.png b/addons/handles/test/screenshots/baselines/AnimatedMorphCube.png new file mode 100644 index 0000000..b64cf1d Binary files /dev/null and b/addons/handles/test/screenshots/baselines/AnimatedMorphCube.png differ diff --git a/addons/handles/test/screenshots/baselines/AnimatedMorphSphere.png b/addons/handles/test/screenshots/baselines/AnimatedMorphSphere.png new file mode 100644 index 0000000..226dbf6 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/AnimatedMorphSphere.png differ diff --git a/addons/handles/test/screenshots/baselines/AntiqueCamera.png b/addons/handles/test/screenshots/baselines/AntiqueCamera.png new file mode 100644 index 0000000..512f8bd Binary files /dev/null and b/addons/handles/test/screenshots/baselines/AntiqueCamera.png differ diff --git a/addons/handles/test/screenshots/baselines/AttenuationTest.png b/addons/handles/test/screenshots/baselines/AttenuationTest.png new file mode 100644 index 0000000..3eb0603 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/AttenuationTest.png differ diff --git a/addons/handles/test/screenshots/baselines/Avocado.png b/addons/handles/test/screenshots/baselines/Avocado.png new file mode 100644 index 0000000..3752f3e Binary files /dev/null and b/addons/handles/test/screenshots/baselines/Avocado.png differ diff --git a/addons/handles/test/screenshots/baselines/BarramundiFish.png b/addons/handles/test/screenshots/baselines/BarramundiFish.png new file mode 100644 index 0000000..a070a48 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/BarramundiFish.png differ diff --git a/addons/handles/test/screenshots/baselines/BoomBox.png b/addons/handles/test/screenshots/baselines/BoomBox.png new file mode 100644 index 0000000..aaa2fb8 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/BoomBox.png differ diff --git a/addons/handles/test/screenshots/baselines/Box.png b/addons/handles/test/screenshots/baselines/Box.png new file mode 100644 index 0000000..bf9c85f Binary files /dev/null and b/addons/handles/test/screenshots/baselines/Box.png differ diff --git a/addons/handles/test/screenshots/baselines/BoxAnimated.png b/addons/handles/test/screenshots/baselines/BoxAnimated.png new file mode 100644 index 0000000..1de258e Binary files /dev/null and b/addons/handles/test/screenshots/baselines/BoxAnimated.png differ diff --git a/addons/handles/test/screenshots/baselines/BoxInterleaved.png b/addons/handles/test/screenshots/baselines/BoxInterleaved.png new file mode 100644 index 0000000..49ba4f4 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/BoxInterleaved.png differ diff --git a/addons/handles/test/screenshots/baselines/BoxTextured.png b/addons/handles/test/screenshots/baselines/BoxTextured.png new file mode 100644 index 0000000..332dff0 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/BoxTextured.png differ diff --git a/addons/handles/test/screenshots/baselines/BoxTexturedNonPowerOfTwo.png b/addons/handles/test/screenshots/baselines/BoxTexturedNonPowerOfTwo.png new file mode 100644 index 0000000..0cd62e8 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/BoxTexturedNonPowerOfTwo.png differ diff --git a/addons/handles/test/screenshots/baselines/BoxVertexColors.png b/addons/handles/test/screenshots/baselines/BoxVertexColors.png new file mode 100644 index 0000000..1e18f99 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/BoxVertexColors.png differ diff --git a/addons/handles/test/screenshots/baselines/BrainStem.png b/addons/handles/test/screenshots/baselines/BrainStem.png new file mode 100644 index 0000000..5e08b71 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/BrainStem.png differ diff --git a/addons/handles/test/screenshots/baselines/Buggy.png b/addons/handles/test/screenshots/baselines/Buggy.png new file mode 100644 index 0000000..4034846 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/Buggy.png differ diff --git a/addons/handles/test/screenshots/baselines/CarbonFibre.png b/addons/handles/test/screenshots/baselines/CarbonFibre.png new file mode 100644 index 0000000..08c69e4 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/CarbonFibre.png differ diff --git a/addons/handles/test/screenshots/baselines/CesiumMan.png b/addons/handles/test/screenshots/baselines/CesiumMan.png new file mode 100644 index 0000000..7f814c2 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/CesiumMan.png differ diff --git a/addons/handles/test/screenshots/baselines/CesiumMilkTruck.png b/addons/handles/test/screenshots/baselines/CesiumMilkTruck.png new file mode 100644 index 0000000..28b0a75 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/CesiumMilkTruck.png differ diff --git a/addons/handles/test/screenshots/baselines/ClearCoatCarPaint.png b/addons/handles/test/screenshots/baselines/ClearCoatCarPaint.png new file mode 100644 index 0000000..34a39af Binary files /dev/null and b/addons/handles/test/screenshots/baselines/ClearCoatCarPaint.png differ diff --git a/addons/handles/test/screenshots/baselines/ClearCoatTest.png b/addons/handles/test/screenshots/baselines/ClearCoatTest.png new file mode 100644 index 0000000..189f5fd Binary files /dev/null and b/addons/handles/test/screenshots/baselines/ClearCoatTest.png differ diff --git a/addons/handles/test/screenshots/baselines/ClearcoatWicker.png b/addons/handles/test/screenshots/baselines/ClearcoatWicker.png new file mode 100644 index 0000000..63c4f6c Binary files /dev/null and b/addons/handles/test/screenshots/baselines/ClearcoatWicker.png differ diff --git a/addons/handles/test/screenshots/baselines/Corset.png b/addons/handles/test/screenshots/baselines/Corset.png new file mode 100644 index 0000000..94d3ad8 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/Corset.png differ diff --git a/addons/handles/test/screenshots/baselines/DamagedHelmet.png b/addons/handles/test/screenshots/baselines/DamagedHelmet.png new file mode 100644 index 0000000..443aca1 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/DamagedHelmet.png differ diff --git a/addons/handles/test/screenshots/baselines/DirectionalLight.png b/addons/handles/test/screenshots/baselines/DirectionalLight.png new file mode 100644 index 0000000..9f0ed6a Binary files /dev/null and b/addons/handles/test/screenshots/baselines/DirectionalLight.png differ diff --git a/addons/handles/test/screenshots/baselines/DragonAttenuation.png b/addons/handles/test/screenshots/baselines/DragonAttenuation.png new file mode 100644 index 0000000..393a772 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/DragonAttenuation.png differ diff --git a/addons/handles/test/screenshots/baselines/Duck.png b/addons/handles/test/screenshots/baselines/Duck.png new file mode 100644 index 0000000..6975a3f Binary files /dev/null and b/addons/handles/test/screenshots/baselines/Duck.png differ diff --git a/addons/handles/test/screenshots/baselines/EmissiveStrengthTest.png b/addons/handles/test/screenshots/baselines/EmissiveStrengthTest.png new file mode 100644 index 0000000..c66a237 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/EmissiveStrengthTest.png differ diff --git a/addons/handles/test/screenshots/baselines/Fox.png b/addons/handles/test/screenshots/baselines/Fox.png new file mode 100644 index 0000000..819c8ba Binary files /dev/null and b/addons/handles/test/screenshots/baselines/Fox.png differ diff --git a/addons/handles/test/screenshots/baselines/GearboxAssy.png b/addons/handles/test/screenshots/baselines/GearboxAssy.png new file mode 100644 index 0000000..e783c01 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/GearboxAssy.png differ diff --git a/addons/handles/test/screenshots/baselines/GlamVelvetSofa.png b/addons/handles/test/screenshots/baselines/GlamVelvetSofa.png new file mode 100644 index 0000000..b93880b Binary files /dev/null and b/addons/handles/test/screenshots/baselines/GlamVelvetSofa.png differ diff --git a/addons/handles/test/screenshots/baselines/InterpolationTest.png b/addons/handles/test/screenshots/baselines/InterpolationTest.png new file mode 100644 index 0000000..8fa55d2 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/InterpolationTest.png differ diff --git a/addons/handles/test/screenshots/baselines/IridescenceLamp.png b/addons/handles/test/screenshots/baselines/IridescenceLamp.png new file mode 100644 index 0000000..3270640 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/IridescenceLamp.png differ diff --git a/addons/handles/test/screenshots/baselines/IridescenceSuzanne.png b/addons/handles/test/screenshots/baselines/IridescenceSuzanne.png new file mode 100644 index 0000000..aded5a4 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/IridescenceSuzanne.png differ diff --git a/addons/handles/test/screenshots/baselines/IridescentDishWithOlives.png b/addons/handles/test/screenshots/baselines/IridescentDishWithOlives.png new file mode 100644 index 0000000..a7efa7f Binary files /dev/null and b/addons/handles/test/screenshots/baselines/IridescentDishWithOlives.png differ diff --git a/addons/handles/test/screenshots/baselines/Lantern.png b/addons/handles/test/screenshots/baselines/Lantern.png new file mode 100644 index 0000000..1d251fc Binary files /dev/null and b/addons/handles/test/screenshots/baselines/Lantern.png differ diff --git a/addons/handles/test/screenshots/baselines/LightsPunctualLamp.png b/addons/handles/test/screenshots/baselines/LightsPunctualLamp.png new file mode 100644 index 0000000..9d99236 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/LightsPunctualLamp.png differ diff --git a/addons/handles/test/screenshots/baselines/MaterialsVariantsShoe.png b/addons/handles/test/screenshots/baselines/MaterialsVariantsShoe.png new file mode 100644 index 0000000..36a2660 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/MaterialsVariantsShoe.png differ diff --git a/addons/handles/test/screenshots/baselines/MetalRoughSpheres.png b/addons/handles/test/screenshots/baselines/MetalRoughSpheres.png new file mode 100644 index 0000000..ec94a67 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/MetalRoughSpheres.png differ diff --git a/addons/handles/test/screenshots/baselines/MetalRoughSpheresNoTextures.png b/addons/handles/test/screenshots/baselines/MetalRoughSpheresNoTextures.png new file mode 100644 index 0000000..d3af0c8 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/MetalRoughSpheresNoTextures.png differ diff --git a/addons/handles/test/screenshots/baselines/MorphPrimitivesTest.png b/addons/handles/test/screenshots/baselines/MorphPrimitivesTest.png new file mode 100644 index 0000000..24bc408 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/MorphPrimitivesTest.png differ diff --git a/addons/handles/test/screenshots/baselines/MorphStressTest.png b/addons/handles/test/screenshots/baselines/MorphStressTest.png new file mode 100644 index 0000000..f93982b Binary files /dev/null and b/addons/handles/test/screenshots/baselines/MorphStressTest.png differ diff --git a/addons/handles/test/screenshots/baselines/MosquitoInAmber.png b/addons/handles/test/screenshots/baselines/MosquitoInAmber.png new file mode 100644 index 0000000..3059927 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/MosquitoInAmber.png differ diff --git a/addons/handles/test/screenshots/baselines/MultiUVTest.png b/addons/handles/test/screenshots/baselines/MultiUVTest.png new file mode 100644 index 0000000..debd0c8 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/MultiUVTest.png differ diff --git a/addons/handles/test/screenshots/baselines/NegativeScaleTest.png b/addons/handles/test/screenshots/baselines/NegativeScaleTest.png new file mode 100644 index 0000000..c28239d Binary files /dev/null and b/addons/handles/test/screenshots/baselines/NegativeScaleTest.png differ diff --git a/addons/handles/test/screenshots/baselines/NormalTangentMirrorTest.png b/addons/handles/test/screenshots/baselines/NormalTangentMirrorTest.png new file mode 100644 index 0000000..470557e Binary files /dev/null and b/addons/handles/test/screenshots/baselines/NormalTangentMirrorTest.png differ diff --git a/addons/handles/test/screenshots/baselines/NormalTangentTest.png b/addons/handles/test/screenshots/baselines/NormalTangentTest.png new file mode 100644 index 0000000..80f6426 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/NormalTangentTest.png differ diff --git a/addons/handles/test/screenshots/baselines/OrientationTest.png b/addons/handles/test/screenshots/baselines/OrientationTest.png new file mode 100644 index 0000000..b190118 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/OrientationTest.png differ diff --git a/addons/handles/test/screenshots/baselines/ReciprocatingSaw.png b/addons/handles/test/screenshots/baselines/ReciprocatingSaw.png new file mode 100644 index 0000000..0eb57b0 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/ReciprocatingSaw.png differ diff --git a/addons/handles/test/screenshots/baselines/RecursiveSkeletons.png b/addons/handles/test/screenshots/baselines/RecursiveSkeletons.png new file mode 100644 index 0000000..cf4bb61 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/RecursiveSkeletons.png differ diff --git a/addons/handles/test/screenshots/baselines/RiggedFigure.png b/addons/handles/test/screenshots/baselines/RiggedFigure.png new file mode 100644 index 0000000..7dd447c Binary files /dev/null and b/addons/handles/test/screenshots/baselines/RiggedFigure.png differ diff --git a/addons/handles/test/screenshots/baselines/RiggedSimple.png b/addons/handles/test/screenshots/baselines/RiggedSimple.png new file mode 100644 index 0000000..b686a05 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/RiggedSimple.png differ diff --git a/addons/handles/test/screenshots/baselines/SheenChair.png b/addons/handles/test/screenshots/baselines/SheenChair.png new file mode 100644 index 0000000..9b1c74f Binary files /dev/null and b/addons/handles/test/screenshots/baselines/SheenChair.png differ diff --git a/addons/handles/test/screenshots/baselines/SimpleInstancing.png b/addons/handles/test/screenshots/baselines/SimpleInstancing.png new file mode 100644 index 0000000..060cfee Binary files /dev/null and b/addons/handles/test/screenshots/baselines/SimpleInstancing.png differ diff --git a/addons/handles/test/screenshots/baselines/SpecGlossVsMetalRough.png b/addons/handles/test/screenshots/baselines/SpecGlossVsMetalRough.png new file mode 100644 index 0000000..0793e94 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/SpecGlossVsMetalRough.png differ diff --git a/addons/handles/test/screenshots/baselines/SpecularTest.png b/addons/handles/test/screenshots/baselines/SpecularTest.png new file mode 100644 index 0000000..252a284 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/SpecularTest.png differ diff --git a/addons/handles/test/screenshots/baselines/SuzanneMorphSparse.png b/addons/handles/test/screenshots/baselines/SuzanneMorphSparse.png new file mode 100644 index 0000000..76f53a9 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/SuzanneMorphSparse.png differ diff --git a/addons/handles/test/screenshots/baselines/TextureCoordinateTest.png b/addons/handles/test/screenshots/baselines/TextureCoordinateTest.png new file mode 100644 index 0000000..24a3685 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/TextureCoordinateTest.png differ diff --git a/addons/handles/test/screenshots/baselines/TextureEncodingTest.png b/addons/handles/test/screenshots/baselines/TextureEncodingTest.png new file mode 100644 index 0000000..5dfd189 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/TextureEncodingTest.png differ diff --git a/addons/handles/test/screenshots/baselines/TextureLinearInterpolationTest.png b/addons/handles/test/screenshots/baselines/TextureLinearInterpolationTest.png new file mode 100644 index 0000000..ce305f0 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/TextureLinearInterpolationTest.png differ diff --git a/addons/handles/test/screenshots/baselines/TextureSettingsTest.png b/addons/handles/test/screenshots/baselines/TextureSettingsTest.png new file mode 100644 index 0000000..4ba58e9 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/TextureSettingsTest.png differ diff --git a/addons/handles/test/screenshots/baselines/TextureTransformMultiTest.png b/addons/handles/test/screenshots/baselines/TextureTransformMultiTest.png new file mode 100644 index 0000000..a10e422 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/TextureTransformMultiTest.png differ diff --git a/addons/handles/test/screenshots/baselines/ToyCar.png b/addons/handles/test/screenshots/baselines/ToyCar.png new file mode 100644 index 0000000..b588f21 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/ToyCar.png differ diff --git a/addons/handles/test/screenshots/baselines/TransmissionRoughnessTest.png b/addons/handles/test/screenshots/baselines/TransmissionRoughnessTest.png new file mode 100644 index 0000000..6f5ef56 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/TransmissionRoughnessTest.png differ diff --git a/addons/handles/test/screenshots/baselines/TransmissionTest.png b/addons/handles/test/screenshots/baselines/TransmissionTest.png new file mode 100644 index 0000000..26ab67a Binary files /dev/null and b/addons/handles/test/screenshots/baselines/TransmissionTest.png differ diff --git a/addons/handles/test/screenshots/baselines/Unicode❤♻Test.png b/addons/handles/test/screenshots/baselines/Unicode❤♻Test.png new file mode 100644 index 0000000..659544f Binary files /dev/null and b/addons/handles/test/screenshots/baselines/Unicode❤♻Test.png differ diff --git a/addons/handles/test/screenshots/baselines/UnlitTest.png b/addons/handles/test/screenshots/baselines/UnlitTest.png new file mode 100644 index 0000000..fb3fab0 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/UnlitTest.png differ diff --git a/addons/handles/test/screenshots/baselines/VC.png b/addons/handles/test/screenshots/baselines/VC.png new file mode 100644 index 0000000..415eec4 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/VC.png differ diff --git a/addons/handles/test/screenshots/baselines/VertexColorTest.png b/addons/handles/test/screenshots/baselines/VertexColorTest.png new file mode 100644 index 0000000..04caa36 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/VertexColorTest.png differ diff --git a/addons/handles/test/screenshots/baselines/WaterBottle.png b/addons/handles/test/screenshots/baselines/WaterBottle.png new file mode 100644 index 0000000..a830ae1 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/WaterBottle.png differ diff --git a/addons/handles/test/screenshots/baselines/XmpMetadataRoundedCube.png b/addons/handles/test/screenshots/baselines/XmpMetadataRoundedCube.png new file mode 100644 index 0000000..3b9313e Binary files /dev/null and b/addons/handles/test/screenshots/baselines/XmpMetadataRoundedCube.png differ diff --git a/addons/handles/test/screenshots/baselines/initial.png b/addons/handles/test/screenshots/baselines/initial.png new file mode 100644 index 0000000..8514998 Binary files /dev/null and b/addons/handles/test/screenshots/baselines/initial.png differ diff --git a/addons/handles/test/screenshots/run.mjs b/addons/handles/test/screenshots/run.mjs new file mode 100644 index 0000000..72ddeb3 --- /dev/null +++ b/addons/handles/test/screenshots/run.mjs @@ -0,0 +1,391 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { createServer } from "node:net"; +import { + access, + copyFile, + mkdir, + mkdtemp, + open, + readFile, + readdir, + rename, + rm, + stat, +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { after, before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); +const directory = dirname(fileURLToPath(import.meta.url)); +const root = resolve(directory, "../../../.."); +const baselines = join(directory, "baselines"); +const output = resolve( + process.env.YAWN_SCREENSHOT_OUTPUT ?? join(directory, "output"), +); +const revision = "d7a3cc8e51d7c573771ae77a57f16b0662a905c6"; +const fixtureDirectory = resolve( + process.env.YAWN_GLTF_FIXTURES ?? + join(homedir(), ".cache", "yawn", "gltf-sample-models", revision), +); +const selected = new Set( + (process.env.YAWN_SCREENSHOT_MODELS ?? "") + .split(",") + .map((name) => name.trim()) + .filter(Boolean), +); +const update = process.env.UPDATE_SCREENSHOTS === "1"; +const session = `yawn-gltf-screenshots-${process.pid}`; +const temporary = await mkdtemp(join(tmpdir(), "yawn-gltf-screenshots-")); +const imageSize = { width: 250, height: 180 }; +const allModels = (await readdir(baselines)) + .filter((name) => name.endsWith(".png") && name !== "initial.png") + .map((name) => basename(name, ".png")) + .sort() + .map((name) => ({ + name, + source: name === "XmpMetadataRoundedCube" ? "xmp.glb" : `${name}.glb`, + path: join(fixtureDirectory, `${name}.glb`), + })); +const models = allModels.filter( + ({ name }) => selected.size === 0 || selected.has(name), +); + +let serverProcess; +let serverOutput = ""; +let url = process.env.YAWN_VISUAL_URL?.replace(/\/+$/u, ""); + +function run(name, args, options = {}) { + return execute(name, args, { + cwd: root, + maxBuffer: 10 * 1024 * 1024, + timeout: 120_000, + ...options, + }); +} + +async function browser(...args) { + const { stdout } = await run( + process.env.AGENT_BROWSER_BIN ?? "agent-browser", + ["--session", session, "--json", ...args], + ); + const result = JSON.parse(stdout.trim()); + if (!result.success) throw new Error(result.error ?? "agent-browser failed"); + return result.data; +} + +async function availablePort() { + const server = createServer(); + await new Promise((accept, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", accept); + }); + const address = server.address(); + assert(address && typeof address !== "string"); + await new Promise((accept, reject) => + server.close((error) => (error ? reject(error) : accept())), + ); + return address.port; +} + +async function startServer() { + if (url) return; + const binary = join(temporary, "yawn-server"); + await run("go", ["build", "-o", binary, "./server"]); + const port = await availablePort(); + serverProcess = spawn(binary, [], { + cwd: root, + env: { + ...process.env, + PORT: String(port), + YAWN_DATABASE: join(temporary, "yawn.db"), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + const record = (chunk) => { + serverOutput = `${serverOutput}${chunk}`.slice(-20_000); + }; + serverProcess.stdout.on("data", record); + serverProcess.stderr.on("data", record); + url = `http://localhost:${port}`; + + const deadline = Date.now() + 600_000; + while (Date.now() < deadline) { + if (serverProcess.exitCode !== null) + throw new Error(`Yawn server exited early:\n${serverOutput}`); + try { + if ((await fetch(`${url}/healthz`)).status === 204) return; + } catch { + // The package build happens before the server begins listening. + } + await new Promise((accept) => setTimeout(accept, 250)); + } + throw new Error(`Yawn server did not become ready:\n${serverOutput}`); +} + +async function validGlb(path) { + try { + const info = await stat(path); + if (info.size < 12) return false; + const file = await open(path, "r"); + try { + const header = Buffer.alloc(12); + await file.read(header, 0, header.length, 0); + return ( + header.toString("ascii", 0, 4) === "glTF" && + header.readUInt32LE(4) === 2 && + header.readUInt32LE(8) === info.size + ); + } finally { + await file.close(); + } + } catch { + return false; + } +} + +async function download(model) { + if (await validGlb(model.path)) return; + await mkdir(fixtureDirectory, { recursive: true }); + const part = `${model.path}.${process.pid}.part`; + const source = new URL( + `${encodeURIComponent(model.name)}/glTF-Binary/${encodeURIComponent(model.source)}`, + `https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/${revision}/2.0/`, + ); + process.stdout.write(`Downloading ${model.name}…\n`); + const response = await fetch(source); + if (!response.ok || !response.body) + throw new Error(`Could not download ${source}: HTTP ${response.status}`); + try { + await pipeline(Readable.fromWeb(response.body), createWriteStream(part)); + } catch (error) { + await rm(part, { force: true }); + throw error; + } + if (!(await validGlb(part))) { + await rm(part, { force: true }); + throw new Error(`${model.name} did not download as a valid GLB`); + } + await rename(part, model.path); +} + +async function prepareModels() { + let next = 0; + await Promise.all( + Array.from({ length: Math.min(4, models.length) }, async () => { + while (next < models.length) await download(models[next++]); + }), + ); +} + +async function imageBytes(path) { + const raw = join(temporary, `${basename(path)}-${Math.random()}.rgb`); + await run("magick", [ + path, + "-filter", + "box", + "-resize", + `${imageSize.width}x${imageSize.height}!`, + "-depth", + "8", + `rgb:${raw}`, + ]); + const bytes = await readFile(raw); + await rm(raw, { force: true }); + return bytes; +} + +async function compareScreenshot(name, actual) { + const expected = join(baselines, `${name}.png`); + if (update) { + await run("magick", [ + actual, + "-filter", + "box", + "-resize", + `${imageSize.width}x${imageSize.height}!`, + expected, + ]); + return; + } + await access(expected); + const [actualBytes, expectedBytes] = await Promise.all([ + imageBytes(actual), + imageBytes(expected), + ]); + assert.equal(actualBytes.length, expectedBytes.length); + + let absoluteError = 0; + let differentPixels = 0; + for (let index = 0; index < actualBytes.length; index += 3) { + let squaredError = 0; + for (let channel = 0; channel < 3; channel++) { + const difference = Math.abs( + actualBytes[index + channel] - expectedBytes[index + channel], + ); + absoluteError += difference; + squaredError += difference * difference; + } + if (Math.sqrt(squaredError / 3) / 255 > 0.1) differentPixels++; + } + const meanError = absoluteError / actualBytes.length / 255; + const differentRatio = differentPixels / (actualBytes.length / 3); + if (meanError <= 0.01 && differentRatio <= 0.02) return; + + await mkdir(output, { recursive: true }); + const saved = join(output, `${name}-actual.png`); + const difference = join(output, `${name}-diff.png`); + await copyFile(actual, saved); + await run("magick", [ + expected, + "(", + actual, + "-filter", + "box", + "-resize", + `${imageSize.width}x${imageSize.height}!`, + ")", + "-compose", + "difference", + "-composite", + "-auto-level", + "-filter", + "point", + "-resize", + "640x460", + difference, + ]); + assert.fail( + `${name} differs from its reference: mean error ${(meanError * 100).toFixed(2)}%, ` + + `${(differentRatio * 100).toFixed(2)}% changed pixels; see ${saved}`, + ); +} + +async function capture(name) { + const path = join(temporary, `${name}.png`); + await browser("screenshot", path); + await compareScreenshot(name, path); +} + +async function diagnostics() { + const { errors } = await browser("errors"); + const { messages } = await browser("console"); + assert.deepEqual(errors, []); + assert.deepEqual(messages, []); + await browser("errors", "--clear"); + await browser("console", "--clear"); +} + +async function saveFailure(name) { + try { + await mkdir(output, { recursive: true }); + await browser("screenshot", join(output, `${name}-failure.png`)); + } catch { + // Preserve the original test failure if the browser cannot capture. + } +} + +before( + async () => { + assert.equal( + allModels.length, + 72, + "expected all 72 reviewed GLB baselines", + ); + assert.equal( + selected.size === 0 || models.length === selected.size, + true, + "YAWN_SCREENSHOT_MODELS contains an unknown model", + ); + await prepareModels(); + await startServer(); + await browser("--webgpu", "--headed", "open", `${url}/view/gltf`); + await browser("set", "viewport", "1000", "720", "1"); + }, + { timeout: 900_000 }, +); + +after(async () => { + try { + await browser("close"); + } catch { + // The browser may already be gone after an initialization failure. + } + if (serverProcess && serverProcess.exitCode === null) serverProcess.kill(); + await rm(temporary, { force: true, recursive: true }); +}); + +test("viewer starts with the local-file prompt", async () => { + const state = await browser( + "eval", + `({ + isolated: crossOriginIsolated, + webgpu: Boolean(navigator.gpu), + promptHidden: document.querySelector("#drop-prompt").hidden, + prompt: document.querySelector("#prompt-message").textContent, + renderHidden: document.querySelector("#render-time").hidden + })`, + ); + assert.deepEqual(state.result, { + isolated: true, + webgpu: true, + promptHidden: false, + prompt: "Drop a glTF file", + renderHidden: true, + }); + await diagnostics(); + await capture("initial"); +}); + +for (const model of models) { + test( + `renders ${model.name}`, + async () => { + try { + await browser("errors", "--clear"); + await browser("console", "--clear"); + await browser( + "eval", + `(() => { + window.__yawnScreenshotReady = false; + const prompt = document.querySelector("#drop-prompt"); + const observer = new MutationObserver(() => { + if (prompt.hidden) { + window.__yawnScreenshotReady = true; + observer.disconnect(); + } + }); + observer.observe(prompt, { attributes: true, attributeFilter: ["hidden"] }); + })()`, + ); + await browser("upload", "#file-picker", model.path); + await browser("wait", "--fn", "window.__yawnScreenshotReady === true"); + await browser("wait", "1200"); + + const state = await browser( + "eval", + `({ + promptHidden: document.querySelector("#drop-prompt").hidden, + renderHidden: document.querySelector("#render-time").hidden, + render: document.querySelector("#render-value").textContent + })`, + ); + assert.equal(state.result.promptHidden, true); + assert.equal(state.result.renderHidden, false); + assert.match(state.result.render, /^(?:—|\d+(?:\.\d+)?) ms$/u); + await diagnostics(); + await capture(model.name); + } catch (error) { + await saveFailure(model.name); + throw error; + } + }, + { timeout: 120_000 }, + ); +} diff --git a/package-lock.json b/package-lock.json index 8e87ef4..127c362 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@codemirror/lang-javascript": "^6.2.5", "@codemirror/theme-one-dark": "^6.1.3", + "agent-browser": "0.33.2", "codemirror": "^6.0.2", "esbuild": "^0.25.9", "monaco-editor": "^0.52.2", @@ -1462,6 +1463,21 @@ "resolved": "addons/handles", "link": true }, + "node_modules/agent-browser": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.33.2.tgz", + "integrity": "sha512-e+TZ0G04uw2rs+lVB8gn0IWTT7ErfiAl3jQ4zNNwyqDhgXWJKhqxYKkyibjuBGXLzx/APlzU3IWAsOVdRwh0DA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "agent-browser": "bin/agent-browser.js" + }, + "engines": { + "node": ">=24.0.0", + "pnpm": ">=11.0.0" + } + }, "node_modules/algoliasearch": { "version": "5.57.0", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.57.0.tgz", diff --git a/package.json b/package.json index 3347c17..863a9b4 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,15 @@ "start": "wasm-pack build core --target web --out-dir pkg --release && vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}", "server": "go run ./server", "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", + "test:handles": "npm test --workspace @yawn/handles", + "test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles" }, "devDependencies": { "@codemirror/lang-javascript": "^6.2.5", "@codemirror/theme-one-dark": "^6.1.3", + "agent-browser": "0.33.2", "codemirror": "^6.0.2", "esbuild": "^0.25.9", "monaco-editor": "^0.52.2", diff --git a/server/app.go b/server/app.go index 33f7038..b39d469 100644 --- a/server/app.go +++ b/server/app.go @@ -35,7 +35,7 @@ func newApplication(store *playgroundStore, packages map[string]memoryAsset, mon return nil, err } pages := make(map[string][]byte) - for _, name := range []string{"home.html", "docs.html", "playground.html"} { + for _, name := range []string{"home.html", "docs.html", "playground.html", "gltf.html"} { content, err := webFiles.ReadFile("web/" + name) if err != nil { return nil, err @@ -63,6 +63,8 @@ func (app *application) routes() http.Handler { mux.HandleFunc("/docs/", app.servePage("docs.html")) mux.HandleFunc("/playground", app.servePlayground) mux.HandleFunc("/playground/", app.servePlayground) + mux.HandleFunc("/view/gltf", app.servePage("gltf.html")) + mux.HandleFunc("/view/gltf/", app.servePage("gltf.html")) mux.HandleFunc("/", app.serveHome) return app.headers(mux) } diff --git a/server/app_test.go b/server/app_test.go index 2f85059..7aff265 100644 --- a/server/app_test.go +++ b/server/app_test.go @@ -144,3 +144,24 @@ func TestSavedPlaygroundPageRoute(t *testing.T) { t.Fatalf("content type = %q", contentType) } } + +func TestGltfViewerPageRoute(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/view/gltf", nil) + response := httptest.NewRecorder() + testApplication(t).routes().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d", response.Code) + } + if contentType := response.Header().Get("Content-Type"); contentType != "text/html; charset=utf-8" { + t.Fatalf("content type = %q", contentType) + } + for _, content := range []string{ + `"@yawn/core": "/pkg/core.js"`, + `"@yawn/handles": "/pkg/handles.js"`, + "Drop a glTF file", + } { + if !bytes.Contains(response.Body.Bytes(), []byte(content)) { + t.Fatalf("viewer page does not contain %q", content) + } + } +} diff --git a/server/web/assets/gltf.css b/server/web/assets/gltf.css new file mode 100644 index 0000000..aee0087 --- /dev/null +++ b/server/web/assets/gltf.css @@ -0,0 +1,185 @@ +:root { + color: #d7e9f8; + background: #06111d; + font-family: + Inter, + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +html, +body, +.viewer { + width: 100%; + height: 100%; + height: 100dvh; + margin: 0; + overflow: hidden; +} + +.viewer { + position: relative; + background: #06111d; +} + +.viewer::after { + position: absolute; + inset: 0; + border: 2px solid transparent; + content: ""; + pointer-events: none; + transition: + border-color 140ms ease, + background 140ms ease; +} + +.viewer.is-dragging::after { + border-color: #35a9ed; + background: rgb(20 112 168 / 9%); +} + +canvas { + display: block; + width: 100%; + height: 100%; + outline: none; + touch-action: none; +} + +.render-time { + position: absolute; + z-index: 2; + top: 18px; + left: 18px; + display: flex; + align-items: baseline; + gap: 8px; + padding: 9px 11px; + color: #718da2; + border: 1px solid #23435a; + border-radius: 7px; + background: rgb(8 25 39 / 82%); + box-shadow: 0 8px 24px rgb(0 8 15 / 18%); + font: + 700 9px/1 ui-monospace, + SFMono-Regular, + Menlo, + monospace; + letter-spacing: 0.08em; + pointer-events: none; + text-transform: uppercase; + backdrop-filter: blur(10px); +} + +.render-time[hidden] { + display: none; +} + +.render-time strong { + color: #8bd5fb; + font-size: 11px; + letter-spacing: 0; + text-transform: none; +} + +.drop-prompt { + position: absolute; + z-index: 3; + top: 50%; + left: 50%; + display: grid; + min-width: min(360px, calc(100% - 36px)); + justify-items: center; + gap: 8px; + padding: 28px 32px; + transform: translate(-50%, -50%); + color: inherit; + border: 1px solid transparent; + border-radius: 14px; + outline: none; + background: transparent; + cursor: pointer; + text-align: center; +} + +.drop-prompt:hover, +.drop-prompt:focus-visible, +.viewer.is-dragging .drop-prompt { + border-color: #25577a; + background: rgb(11 41 65 / 72%); + box-shadow: 0 18px 50px rgb(0 10 18 / 22%); +} + +.drop-prompt[hidden] { + display: none; +} + +.viewer.is-dragging .drop-prompt { + display: grid; +} + +.drop-icon { + display: grid; + width: 48px; + height: 48px; + margin-bottom: 7px; + place-items: center; + color: #54bdf6; + border: 1px solid #25577a; + border-radius: 13px; + background: #0b2941; + box-shadow: 0 0 34px rgb(46 171 238 / 15%); + font: + 700 19px/1 ui-monospace, + monospace; +} + +.drop-prompt strong { + color: #c5d9e8; + font-size: 14px; + letter-spacing: -0.01em; +} + +.drop-prompt small { + color: #698297; + font-size: 10px; +} + +.drop-prompt.is-error .drop-icon { + color: #f0a5a5; + border-color: #6f3540; + background: #351b27; + box-shadow: 0 0 34px rgb(205 78 91 / 12%); +} + +.drop-prompt.is-error strong { + color: #e8b5b5; +} + +@media (max-width: 560px) { + .render-time { + top: 12px; + left: 12px; + } + + .drop-prompt { + padding: 24px 20px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + } +} diff --git a/server/web/assets/gltf.js b/server/web/assets/gltf.js new file mode 100644 index 0000000..6c618b1 --- /dev/null +++ b/server/web/assets/gltf.js @@ -0,0 +1,309 @@ +import { + AmbientLight, + ArcRotateCamera, + Scene, + importGltf, +} from "@yawn/handles"; + +const elements = { + canvas: document.getElementById("canvas"), + detail: document.getElementById("prompt-detail"), + dropZone: document.getElementById("drop-zone"), + filePicker: document.getElementById("file-picker"), + message: document.getElementById("prompt-message"), + prompt: document.getElementById("drop-prompt"), + renderTime: document.getElementById("render-time"), + renderValue: document.getElementById("render-value"), +}; + +let activeViewer; +let loading = false; + +function setPrompt(message, detail, error = false) { + elements.message.textContent = message; + elements.detail.textContent = detail; + elements.prompt.classList.toggle("is-error", error); + elements.prompt.hidden = false; +} + +function resetPrompt() { + setPrompt( + "Drop a glTF file", + "GLB or self-contained glTF · processed locally", + ); +} + +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), + ); + elements.canvas.replaceWith(canvas); + elements.canvas = canvas; + return canvas; +} + +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); + const tz = 2 * (rx * y - ry * x); + return [ + x + rw * tx + ry * tz - rz * ty, + y + rw * ty + rz * tx - rx * tz, + z + rw * tz + rx * ty - ry * tx, + ]; +} + +function findBounds(scene, meshes) { + const minimum = [Infinity, Infinity, Infinity]; + const maximum = [-Infinity, -Infinity, -Infinity]; + + for (const mesh of meshes) { + const bounds = scene.array("bounds").row(mesh.id); + const position = Array.from(mesh.position); + const rotor = Array.from(mesh.rotor); + const scale = Array.from(mesh.scale); + + for (const x of [bounds[0], bounds[4]]) + for (const y of [bounds[1], bounds[5]]) + for (const z of [bounds[2], bounds[6]]) { + const corner = rotate(rotor, [ + x * scale[0], + y * scale[1], + z * scale[2], + ]).map((value, lane) => value + position[lane]); + for (let lane = 0; lane < 3; lane++) { + minimum[lane] = Math.min(minimum[lane], corner[lane]); + maximum[lane] = Math.max(maximum[lane], corner[lane]); + } + } + } + + if (![...minimum, ...maximum].every(Number.isFinite)) + throw new Error("The file does not contain renderable mesh bounds."); + + return { + center: minimum.map((value, lane) => (value + maximum[lane]) / 2), + extents: maximum.map((value, lane) => value - minimum[lane]), + radius: Math.max( + 0.01, + Math.hypot(...maximum.map((value, lane) => value - minimum[lane])) / 2, + ), + }; +} + +function cameraOptions(scene, meshes, canvas, bounds) { + const aspect = canvas.width / canvas.height; + const verticalFov = meshes.camera?.fov ?? 0.8; + const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect); + let alpha = 5.0861; + let beta = 1.5454; + if (meshes.camera?.direction) { + const [x, y, z] = meshes.camera.direction; + alpha = Math.atan2(x, z); + beta = Math.acos(Math.max(-1, Math.min(1, y))); + } else if (bounds.extents[2] < bounds.extents[0] * 0.75) { + alpha = 0; + beta = 1.45; + } else if (bounds.extents[0] < bounds.extents[2] * 0.75) { + alpha = Math.PI / 2; + beta = 1.45; + } + const cameraDirection = [ + Math.sin(beta) * Math.sin(alpha), + Math.cos(beta), + Math.sin(beta) * Math.cos(alpha), + ]; + const right = [Math.cos(alpha), 0, -Math.sin(alpha)]; + const up = [ + -Math.cos(beta) * Math.sin(alpha), + Math.sin(beta), + -Math.cos(beta) * Math.cos(alpha), + ]; + const tangentX = Math.tan(horizontalFov / 2); + const tangentY = Math.tan(verticalFov / 2); + let distance = 0; + + for (const mesh of meshes) { + const positions = scene.geometryData(mesh.geometryId, "positions"); + const position = Array.from(mesh.position); + const rotor = Array.from(mesh.rotor); + const scale = Array.from(mesh.scale); + for (let index = 0; index < positions.length; index += 3) { + const point = rotate(rotor, [ + positions[index] * scale[0], + positions[index + 1] * scale[1], + positions[index + 2] * scale[2], + ]).map((value, lane) => value + position[lane] - bounds.center[lane]); + const towardCamera = point.reduce( + (value, lane, index) => value + lane * cameraDirection[index], + 0, + ); + const horizontal = Math.abs( + point.reduce((value, lane, index) => value + lane * right[index], 0), + ); + const vertical = Math.abs( + point.reduce((value, lane, index) => value + lane * up[index], 0), + ); + distance = Math.max( + distance, + towardCamera + horizontal / tangentX, + towardCamera + vertical / tangentY, + ); + } + } + distance = Math.max(0.01, distance * 1.08); + + return { + targetPosition: bounds.center, + alpha, + beta, + radius: distance, + fov: verticalFov, + near: Math.max(0.001, (distance - bounds.radius) * 0.25), + far: Math.max(1000, distance + bounds.radius * 10), + aspect, + controls: { element: canvas, pointer: true }, + }; +} + +function isGltf(file) { + return /\.(?:glb|gltf)$/iu.test(file.name); +} + +async function openFile(file) { + if (loading) return; + if (!file || !isGltf(file)) { + setPrompt("That file is not glTF", "Drop a .glb or .gltf file.", true); + return; + } + if (!crossOriginIsolated) { + setPrompt( + "Cross-origin isolation is unavailable", + "This viewer needs COOP and COEP response headers.", + true, + ); + return; + } + if (!navigator.gpu) { + setPrompt( + "WebGPU is unavailable", + "Open this page in a browser with WebGPU support.", + true, + ); + return; + } + + loading = true; + disposeViewer(); + elements.renderTime.hidden = true; + setPrompt("Opening model…", `${file.name} stays on this device.`); + + const url = URL.createObjectURL(file); + let scene; + try { + const canvas = replaceCanvas(); + scene = new Scene(canvas, { + arenaBytes: 384 * 1024 * 1024, + hdr: true, + }); + await scene.ready; + scene.array("sceneAccent").row(0).set([1, 1, 1, 1]); + + const meshes = await importGltf(scene, url); + if (!meshes.length) + throw new Error("The file does not contain any triangle meshes."); + + const bounds = findBounds(scene, meshes); + const light = new AmbientLight(scene, { + color: [1, 0.94, 0.86], + intensity: 0.28, + }); + const camera = new ArcRotateCamera( + scene, + cameraOptions(scene, meshes, canvas, bounds), + ); + await Promise.all([light.ready, camera.ready]); + + elements.renderValue.textContent = "— ms"; + elements.renderTime.hidden = false; + const stopProfile = scene.core.onProfile((profile) => { + elements.renderValue.textContent = `${profile.milliseconds.toFixed(2)} ms`; + }); + const profiling = await scene.core.setProfiler(true); + if (!profiling) + elements.renderTime.title = + "GPU timestamp queries are not supported by this adapter."; + else { + elements.renderTime.removeAttribute("title"); + scene.array("sceneAccent").row(0)[3] = 1; + } + + activeViewer = { + camera, + scene, + stopProfile, + signals: scene.core.array("signals").row(0), + }; + markDirtyEveryFrame(activeViewer); + resetPrompt(); + elements.prompt.hidden = true; + } catch (error) { + scene?.dispose(); + const message = error instanceof Error ? error.message : String(error); + setPrompt("Could not open this model", message, true); + } finally { + URL.revokeObjectURL(url); + loading = false; + } +} + +elements.prompt.addEventListener("click", () => { + if (!loading) elements.filePicker.click(); +}); + +elements.filePicker.addEventListener("change", () => { + const [file] = elements.filePicker.files; + void openFile(file); + elements.filePicker.value = ""; +}); + +for (const type of ["dragenter", "dragover"]) { + elements.dropZone.addEventListener(type, (event) => { + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + elements.dropZone.classList.add("is-dragging"); + }); +} + +elements.dropZone.addEventListener("dragleave", (event) => { + if (!elements.dropZone.contains(event.relatedTarget)) + elements.dropZone.classList.remove("is-dragging"); +}); + +elements.dropZone.addEventListener("drop", (event) => { + event.preventDefault(); + elements.dropZone.classList.remove("is-dragging"); + const [file] = event.dataTransfer?.files ?? []; + void openFile(file); +}); + +addEventListener("beforeunload", disposeViewer); +resetPrompt(); diff --git a/server/web/gltf.html b/server/web/gltf.html new file mode 100644 index 0000000..76ee437 --- /dev/null +++ b/server/web/gltf.html @@ -0,0 +1,47 @@ + + + + + + + + glTF Viewer — Yawn + + + + + +
+ + + + + + +
+ +