Add local glTF viewer and visual coverage

Amp-Thread-ID: https://ampcode.com/threads/T-01a02d1c-27fc-7761-9f51-ed97b2dd4e3e
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-24 10:51:26 +00:00
co-authored by heaust
parent df8b69e09d
commit 951449db0b
99 changed files with 4004 additions and 376 deletions
+5
View File
@@ -18,7 +18,12 @@ if ! command -v git-lfs >/dev/null; then
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y git-lfs sudo apt-get install -y git-lfs
fi 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 install --local
git lfs pull --include="docs/public/models/sponza.glb" git lfs pull --include="docs/public/models/sponza.glb"
npm ci npm ci
npx agent-browser install
amp orb services ensure amp orb services ensure
+1
View File
@@ -32,6 +32,7 @@ docs/.vitepress/cache/
docs/.vitepress/dist/ docs/.vitepress/dist/
server/data/ server/data/
yawn-data/ yawn-data/
addons/handles/test/screenshots/output/
# Amp runtime artifacts # Amp runtime artifacts
.amp/in/ .amp/in/
+4
View File
@@ -4,6 +4,10 @@
"description": "Conventional scene handles over Yawn's worker and shared render data", "description": "Conventional scene handles over Yawn's worker and shared render data",
"type": "module", "type": "module",
"exports": "./src/index.ts", "exports": "./src/index.ts",
"scripts": {
"test": "node test/run.mjs",
"test:screenshots": "node --test --test-concurrency=1 test/screenshots/run.mjs"
},
"dependencies": { "dependencies": {
"@yawn/core": "0.1.0" "@yawn/core": "0.1.0"
} }
+50 -12
View File
@@ -2,7 +2,15 @@ import { Node, type NodeOptions } from "./Node";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { PBRMaterial } from "./materials/PBRMaterial"; 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 VertexKind = (typeof VertexKinds)[number];
export type MeshOptions = NodeOptions & { export type MeshOptions = NodeOptions & {
geometryId?: number; geometryId?: number;
@@ -27,10 +35,17 @@ export class Mesh extends Node {
this.ready = nodeReady.then(async () => { this.ready = nodeReady.then(async () => {
if (options.material) await options.material.ready; if (options.material) await options.material.ready;
await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32"); await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32");
this.vertexCount = Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) / 3; this.vertexCount =
this.indexCount = Number(scene.geometryData(this.geometryId, "indices")?.length ?? 0); Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) /
this.instanceOf = options.geometryId === undefined ? this.id : this.geometryId; 3;
scene.array("meshInfo").write(this.id, [ 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, this.geometryId,
options.material?.id ?? 0, options.material?.id ?? 0,
options.visible === false ? 0 : 1, options.visible === false ? 0 : 1,
@@ -60,7 +75,8 @@ export class Mesh extends Node {
} }
set material(value: PBRMaterial) { 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.markDirty(true);
this.scene.array("meshInfo").row(this.id)[1] = value.id; this.scene.array("meshInfo").row(this.id)[1] = value.id;
void this.scene.updateRenderGraph().catch(() => undefined); void this.scene.updateRenderGraph().catch(() => undefined);
@@ -72,13 +88,18 @@ export class Mesh extends Node {
} }
async setVertexData(kind: VertexKind, data: ArrayLike<number>) { async setVertexData(kind: VertexKind, data: ArrayLike<number>) {
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.ready;
await this.#setVertexData(kind, data, true); await this.#setVertexData(kind, data, true);
return this; return this;
} }
async #setVertexData(kind: VertexKind, data: ArrayLike<number>, makeUnique: boolean) { async #setVertexData(
kind: VertexKind,
data: ArrayLike<number>,
makeUnique: boolean,
) {
if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) { if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) {
const original = this.geometryId; const original = this.geometryId;
this.geometryId = await this.scene.cloneGeometry(original); this.geometryId = await this.scene.cloneGeometry(original);
@@ -86,7 +107,15 @@ export class Mesh extends Node {
this.scene.referenceGeometry(this.geometryId); this.scene.referenceGeometry(this.geometryId);
this.instanceOf = this.id; this.instanceOf = this.id;
this.scene.markDirty(true); 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 === "positions") this.vertexCount = data.length / 3;
if (kind === "indices") this.indexCount = data.length; if (kind === "indices") this.indexCount = data.length;
@@ -96,10 +125,16 @@ export class Mesh extends Node {
async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) { async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) {
await Promise.all([this.ready, material.ready]); 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 list = Array.isArray(faces) ? faces : [faces];
const maximum = Math.max(...list); 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); this.scene.markDirty(true);
for (const face of list) { for (const face of list) {
if (!Number.isInteger(face) || face < 0) throw new RangeError("face"); 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]); minimum[lane] = Math.min(minimum[lane], positions[index + lane]);
maximum[lane] = Math.max(maximum[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() { override async dispose() {
+584 -122
View File
@@ -28,6 +28,7 @@ type PostProcessState = {
type TextureState = GraphTexture & { type TextureState = GraphTexture & {
number: number; number: number;
source?: string | ImageBitmap; source?: string | ImageBitmap;
sampler?: Omit<GraphSampler, "id">;
}; };
const rows = [ const rows = [
@@ -40,7 +41,9 @@ const rows = [
["cameras", 80, "f32"], ["cameras", 80, "f32"],
["cameraMatrices", 80, "f32"], ["cameraMatrices", 80, "f32"],
["materials", 48, "f32"], ["materials", 48, "f32"],
["materialTextures", 32, "u32"], ["materialTextures", 64, "u32"],
["materialExtensions", 112, "f32"],
["materialTextureInfo", 416, "f32"],
["pointLights", 32, "f32"], ["pointLights", 32, "f32"],
["rectAreaLights", 48, "f32"], ["rectAreaLights", 48, "f32"],
["spotLights", 32, "f32"], ["spotLights", 32, "f32"],
@@ -49,6 +52,11 @@ const rows = [
["sceneAccent", 16, "f32"], ["sceneAccent", 16, "f32"],
] as const; ] as const;
const compactLightLimit = 500;
const compactPointStart = 11;
const compactSpotCount = compactPointStart + compactLightLimit;
const compactSpotStart = compactSpotCount + 1;
const clusterShader = /* wgsl */ ` const clusterShader = /* wgsl */ `
@group(0) @binding(0) var<storage, read> pointLights: array<vec4<f32>>; @group(0) @binding(0) var<storage, read> pointLights: array<vec4<f32>>;
@group(0) @binding(1) var<storage, read> rectLights: array<vec4<f32>>; @group(0) @binding(1) var<storage, read> rectLights: array<vec4<f32>>;
@@ -61,12 +69,20 @@ const clusterShader = /* wgsl */ `
fn main(@builtin(global_invocation_id) id: vec3<u32>) { fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x != 0u) { return; } if (id.x != 0u) { return; }
var count = 0u; var count = 0u;
var pointCount = 0u;
var spotCount = 0u;
var light = vec3<f32>(0.0); var light = vec3<f32>(0.0);
var pointLight = vec3<f32>(0.0); var pointLight = vec3<f32>(0.0);
var spotLight = vec3<f32>(0.0); var spotLight = vec3<f32>(0.0);
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) { for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) {
let enabled = pointLights[i * 2u + 1u].y; let enabled = pointLights[i * 2u + 1u].y;
count += select(0u, 1u, enabled != 0.0); 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; let contribution = pointLights[i * 2u].rgb * pointLights[i * 2u].a * enabled * 0.02;
light += contribution; light += contribution;
pointLight += contribution; pointLight += contribution;
@@ -79,6 +95,12 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) { for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) {
let enabled = spotLights[i * 2u + 1u].w; let enabled = spotLights[i * 2u + 1u].w;
count += select(0u, 1u, enabled != 0.0); 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; let contribution = spotLights[i * 2u].rgb * spotLights[i * 2u].a * enabled * 0.02;
light += contribution; light += contribution;
spotLight += contribution; spotLight += contribution;
@@ -102,6 +124,8 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
clusters[7] = bitcast<u32>(spotLight.r); clusters[7] = bitcast<u32>(spotLight.r);
clusters[8] = bitcast<u32>(spotLight.g); clusters[8] = bitcast<u32>(spotLight.g);
clusters[9] = bitcast<u32>(spotLight.b); clusters[9] = bitcast<u32>(spotLight.b);
clusters[10] = pointCount;
clusters[${compactSpotCount}] = spotCount;
}`; }`;
const basicForwardShader = /* wgsl */ ` const basicForwardShader = /* wgsl */ `
@@ -162,10 +186,70 @@ fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(color, base.a); return vec4<f32>(color, base.a);
}`; }`;
function pbrShader(mask: number) { function pbrShader(
const baseTexture = mask & 1; mask: number,
const materialTexture = mask & 2; flags: number,
const normalTexture = mask & 4; hasColors: boolean,
hasUvs2: boolean,
transmission: boolean,
textureInfo: ArrayLike<number>,
extensionInfo: ArrayLike<number>,
) {
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 */ ` return /* wgsl */ `
struct VertexOutput { struct VertexOutput {
@invariant @builtin(position) position: vec4<f32>, @invariant @builtin(position) position: vec4<f32>,
@@ -173,8 +257,9 @@ struct VertexOutput {
@location(1) normal: vec3<f32>, @location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>, @location(2) uv: vec2<f32>,
@location(3) tangent: vec4<f32>, @location(3) tangent: vec4<f32>,
@location(4) @interpolate(flat) mesh: u32, @location(4) color: vec4<f32>,
@location(5) @interpolate(flat) material: u32, @location(5) uv2: vec2<f32>,
@location(6) @interpolate(flat) material: u32,
} }
@group(0) @binding(0) var<storage, read> clusters: array<u32>; @group(0) @binding(0) var<storage, read> clusters: array<u32>;
@@ -188,9 +273,8 @@ struct VertexOutput {
@group(0) @binding(8) var<storage, read> pointLights: array<vec4<f32>>; @group(0) @binding(8) var<storage, read> pointLights: array<vec4<f32>>;
@group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>; @group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>;
${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""} ${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""}
${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d<f32>;" : ""} ${names.map((name, slot) => (texture(slot) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d<f32>;` : "")).join("\n")}
${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d<f32>;" : ""} ${transmission ? "@group(2) @binding(0) var sceneColor: texture_2d<f32>;\n@group(2) @binding(1) var sceneSampler: sampler;" : ""}
${normalTexture ? "@group(1) @binding(3) var normalTexture: texture_2d<f32>;" : ""}
fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> { fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
return value + 2.0 * cross(rotor.xyz, cross(rotor.xyz, value) + rotor.w * value); return value + 2.0 * cross(rotor.xyz, cross(rotor.xyz, value) + rotor.w * value);
@@ -202,6 +286,8 @@ fn vertex(
@location(1) localNormal: vec3<f32>, @location(1) localNormal: vec3<f32>,
@location(2) uv: vec2<f32>, @location(2) uv: vec2<f32>,
${normalTexture ? "@location(3) localTangent: vec4<f32>," : ""} ${normalTexture ? "@location(3) localTangent: vec4<f32>," : ""}
${hasColors ? "@location(4) color: vec4<f32>," : ""}
${hasUvs2 ? "@location(5) uv2: vec2<f32>," : ""}
@builtin(instance_index) packed: u32, @builtin(instance_index) packed: u32,
) -> VertexOutput { ) -> VertexOutput {
let instance = packed & 65535u; let instance = packed & 65535u;
@@ -223,79 +309,184 @@ fn vertex(
output.normal = normalize(rotate(rotors[instance], localNormal / scale)); output.normal = normalize(rotate(rotors[instance], localNormal / scale));
output.uv = uv; 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.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.color = ${hasColors ? "color" : "vec4(1.0)"};
output.material = packed >> 16u; output.uv2 = ${hasUvs2 ? "uv2" : "uv"};
output.material = (packed >> 16u) - 1u;
return output; return output;
} }
fn studioEnvironment(direction: vec3<f32>) -> vec3<f32> {
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<f32>, world: vec3<f32>) -> vec3<f32> {
let colorIntensity = pointLights[index * 2u];
let state = pointLights[index * 2u + 1u];
if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); }
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<f32>, world: vec3<f32>) -> vec3<f32> {
let colorIntensity = spotLights[index * 2u];
let state = spotLights[index * 2u + 1u];
if (state.w == 0.0 || state.x <= 0.0) { return vec3(0.0); }
let fromLight = world - positions[index].xyz;
let distance = length(fromLight);
let direction = normalize(rotate(rotors[index], vec3<f32>(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 @fragment
fn fragment(input: VertexOutput) -> @location(0) vec4<f32> { fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4<f32> {
let fallback = meshInfo[input.mesh * 4u + 1u]; let factor = materials[input.material * 3u];
let material = select(fallback, input.material - 1u, input.material != 0u); let properties = materials[input.material * 3u + 1u];
let factor = materials[material * 3u]; let extra = materials[input.material * 3u + 2u];
let properties = materials[material * 3u + 1u]; let extension0 = vec4(${Array.from(extensionInfo).slice(0, 4).map(number).join(", ")});
let extra = materials[material * 3u + 2u]; let extension1 = vec4(${Array.from(extensionInfo).slice(4, 8).map(number).join(", ")});
let base = factor * ${baseTexture ? "textureSample(baseTexture, materialSampler, input.uv)" : "vec4(1.0)"}; let extension2 = vec4(${Array.from(extensionInfo).slice(8, 12).map(number).join(", ")});
let packedMaterial = ${materialTexture ? "textureSample(materialTexture, materialSampler, input.uv)" : "vec4(1.0)"}; let extension3 = vec4(${Array.from(extensionInfo).slice(12, 16).map(number).join(", ")});
let metallic = clamp(properties.x * ${materialTexture ? "packedMaterial.b" : "1.0"}, 0.0, 1.0); let extension4 = vec4(${Array.from(extensionInfo).slice(16, 20).map(number).join(", ")});
let roughness = clamp(properties.y * ${materialTexture ? "packedMaterial.g" : "1.0"}, 0.04, 1.0); 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); 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<f32>(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<f32>(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));` : ""}
let view = normalize(cameraMatrices[4].xyz - input.world); let view = normalize(cameraMatrices[4].xyz - input.world);
let lightDirection = normalize(vec3<f32>(0.4, 0.7, 0.6)); let lightDirection = normalize(vec3<f32>(0.4, 0.7, 0.6));
let halfVector = normalize(lightDirection + view); let halfVector = normalize(lightDirection + view);
let nDotL = max(dot(normal, lightDirection), 0.0); 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 nDotH = max(dot(normal, halfVector), 0.0);
let f0 = mix(vec3(0.04), base.rgb, metallic); let vDotH = max(dot(view, halfVector), 0.0);
let specular = f0 * pow(nDotH, max(2.0, 2.0 / (roughness * roughness) - 2.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<f32>(bitcast<f32>(clusters[1]), bitcast<f32>(clusters[2]), bitcast<f32>(clusters[3])); let clusterLight = vec3<f32>(bitcast<f32>(clusters[1]), bitcast<f32>(clusters[2]), bitcast<f32>(clusters[3]));
let pointAmbient = vec3<f32>(bitcast<f32>(clusters[4]), bitcast<f32>(clusters[5]), bitcast<f32>(clusters[6])); let pointAmbient = vec3<f32>(bitcast<f32>(clusters[4]), bitcast<f32>(clusters[5]), bitcast<f32>(clusters[6]));
let spotAmbient = vec3<f32>(bitcast<f32>(clusters[7]), bitcast<f32>(clusters[8]), bitcast<f32>(clusters[9])); let spotAmbient = vec3<f32>(bitcast<f32>(clusters[7]), bitcast<f32>(clusters[8]), bitcast<f32>(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<f32>(0.0); var pointDiffuse = vec3<f32>(0.0);
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) { let pointCount = clusters[10];
let colorIntensity = pointLights[i * 2u]; if (pointCount <= ${compactLightLimit}u) {
let state = pointLights[i * 2u + 1u]; for (var light = 0u; light < pointCount; light++) {
if (state.y == 0.0 || state.x <= 0.0) { continue; } pointDiffuse += pointContribution(clusters[${compactPointStart}u + light], normal, input.world);
let offset = positions[i].xyz - input.world; }
let distance = length(offset); } else {
let attenuation = pow(max(1.0 - distance / state.x, 0.0), 2.0); for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) {
let incidence = max(dot(normal, offset / max(distance, 0.0001)), 0.0); pointDiffuse += pointContribution(light, normal, input.world);
pointDiffuse += colorIntensity.rgb * colorIntensity.a * attenuation * incidence * 0.08; }
} }
var spotDiffuse = vec3<f32>(0.0); var spotDiffuse = vec3<f32>(0.0);
for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) { let spotCount = clusters[${compactSpotCount}];
let colorIntensity = spotLights[i * 2u]; if (spotCount <= ${compactLightLimit}u) {
let state = spotLights[i * 2u + 1u]; for (var light = 0u; light < spotCount; light++) {
if (state.w == 0.0 || state.x <= 0.0) { continue; } spotDiffuse += spotContribution(clusters[${compactSpotStart}u + light], normal, input.world);
let fromLight = input.world - positions[i].xyz; }
let distance = length(fromLight); } else {
let lightDirection = normalize(rotate(rotors[i], vec3<f32>(0.0, 0.0, -1.0))); for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) {
let cone = smoothstep(cos(state.z), cos(state.y), dot(lightDirection, fromLight / max(distance, 0.0001))); spotDiffuse += spotContribution(light, normal, input.world);
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 defaultLight = select(1.0, 0.18, clusters[0] != 0u); let defaultLight = select(1.0, 0.18, clusters[0] != 0u);
let diffuse = base.rgb * (1.0 - metallic) * nDotL * defaultLight; let occlusion = mix(1.0, ${sample(4)}.r, extension2.w);
let emissive = vec3(properties.z, properties.w, extra.x); 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); 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<f32>(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 */ ` const clearShader = /* wgsl */ `
struct Accent { color: vec4<f32> } @vertex fn vertex() -> @builtin(position) vec4<f32> {
@group(0) @binding(0) var<uniform> accent: Accent; return vec4(0.0);
struct VertexOutput { @builtin(position) position: vec4<f32> }
@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;
} }
@fragment fn fragment() -> @location(0) vec4<f32> { @fragment fn fragment() -> @location(0) vec4<f32> {
return vec4(accent.color.rgb, 1.0); return vec4(0.0);
}`; }`;
const fullscreenVertex = /* wgsl */ ` const fullscreenVertex = /* wgsl */ `
@@ -311,6 +502,28 @@ struct VertexOutput {
return output; return output;
}`; }`;
const copyShader = `${fullscreenVertex}
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var sourceSampler: sampler;
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(source, sourceSampler, input.uv);
}`;
const studioBackground = /* wgsl */ `
fn studioBackground(uv: vec2<f32>) -> vec3<f32> {
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<f32> {
return vec4(studioBackground(input.uv), 1.0);
}`;
function effectFragment(kind: string, options: Record<string, unknown>) { function effectFragment(kind: string, options: Record<string, unknown>) {
const amount = Number(options.amount ?? options.exposure ?? 1); const amount = Number(options.amount ?? options.exposure ?? 1);
const safeAmount = Number.isFinite(amount) ? amount : 1; const safeAmount = Number.isFinite(amount) ? amount : 1;
@@ -330,7 +543,10 @@ function effectFragment(kind: string, options: Record<string, unknown>) {
}`; }`;
} }
function presentShader(toneMap: string) { function presentShader(
toneMap: string,
composite: "studio" | "scene" | undefined,
) {
const tone = const tone =
toneMap === "reinhard" toneMap === "reinhard"
? "color / (color + vec3(1.0))" ? "color / (color + vec3(1.0))"
@@ -338,12 +554,14 @@ function presentShader(toneMap: string) {
? "clamp(color, vec3(0.0), vec3(1.0))" ? "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))"; : "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))";
return `${fullscreenVertex} return `${fullscreenVertex}
${composite === "studio" ? studioBackground : ""}
@group(0) @binding(0) var source: texture_2d<f32>; @group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var sourceSampler: sampler; @group(0) @binding(1) var sourceSampler: sampler;
${composite === "scene" ? "@group(0) @binding(2) var scene: texture_2d<f32>;" : ""}
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> { @fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
let value = textureSample(source, sourceSampler, input.uv); let value = textureSample(source, sourceSampler, input.uv);
let color = max(value.rgb, vec3(0.0)); 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}, value.a); return vec4(${tone}, ${composite ? "1.0" : "value.a"});
}`; }`;
} }
@@ -402,7 +620,12 @@ export class Scene {
constructor( constructor(
canvas: HTMLCanvasElement, 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.hdr = options.hdr ?? true;
this.core = new YawnCore(canvas, { this.core = new YawnCore(canvas, {
@@ -429,7 +652,22 @@ export class Scene {
const material = await this.core.allocateObject("materials"); const material = await this.core.allocateObject("materials");
this.core this.core
.array("materials") .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); if (fps !== undefined) await this.core.setFps(fps);
await this.#compileRenderGraph(); await this.#compileRenderGraph();
return this; return this;
@@ -471,7 +709,8 @@ export class Scene {
const value = Reflect.get(target, property, target); const value = Reflect.get(target, property, target);
if (typeof value !== "function") return value; if (typeof value !== "function") return value;
if (property === "constructor") 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[]) => { return (...arguments_: unknown[]) => {
const result = Reflect.apply(value, target, arguments_); const result = Reflect.apply(value, target, arguments_);
this.markDirty(); this.markDirty();
@@ -570,9 +809,21 @@ export class Scene {
{ {
name: "materialTextures", name: "materialTextures",
rows: materialCapacity, rows: materialCapacity,
stride: 32, stride: 64,
format: "u32" as const, 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); ].filter((request) => this.array(request.name).rows < request.rows);
if (growth.length) await this.core.createRowsBatch(growth); if (growth.length) await this.core.createRowsBatch(growth);
} }
@@ -623,7 +874,11 @@ export class Scene {
async allocateMaterial() { async allocateMaterial() {
await this.ready; await this.ready;
const id = await this.core.allocateObject("materials"); 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; return id;
} }
@@ -713,6 +968,7 @@ export class Scene {
normals: 3, normals: 3,
tangents: 4, tangents: 4,
uvs: 2, uvs: 2,
uvs2: 2,
colors: 4, colors: 4,
indices: 1, indices: 1,
}; };
@@ -850,9 +1106,34 @@ export class Scene {
} }
const computeIds = [...this.#computePasses.keys()]; const computeIds = [...this.#computePasses.keys()];
const renderedMeshes = [...this.#meshes.values()].filter( const renderCategory = (material: number) => {
(mesh) => mesh.vertexCount > 0, 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"; const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
addTexture({ addTexture({
id: "hdr", id: "hdr",
@@ -868,6 +1149,14 @@ export class Scene {
usage: ["render"], usage: ["render"],
transient: true, 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: "linear", magFilter: "linear", minFilter: "linear" });
addSampler({ addSampler({
id: "material-linear", id: "material-linear",
@@ -878,14 +1167,21 @@ export class Scene {
addressModeV: "repeat", addressModeV: "repeat",
anisotropyClamp: 16, anisotropyClamp: 16,
}); });
for (const { number: _, source: __, ...texture } of this.#textures.values()) for (const {
number: _,
source: __,
sampler,
...texture
} of this.#textures.values()) {
addTexture(texture); addTexture(texture);
if (sampler) addSampler({ id: `${texture.id}-sampler`, ...sampler });
}
let previous = computeIds.length ? computeIds : ["cluster-lights"]; let previous = computeIds.length ? computeIds : ["cluster-lights"];
if (!renderedMeshes.length) { if (!renderedMeshes.length) {
renderPipelines.push({ renderPipelines.push({
id: "empty-forward", id: "empty-forward",
code: emptyForwardShader, code: clearShader,
vertex: { entry: "vertex" }, vertex: { entry: "vertex" },
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
}); });
@@ -894,9 +1190,8 @@ export class Scene {
type: "render", type: "render",
pipeline: "empty-forward", pipeline: "empty-forward",
after: previous, after: previous,
bindings: [{ group: 0, binding: 0, resource: "accent" }],
color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }], color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }],
draw: { vertices: 3 }, draw: { vertices: 0 },
}); });
previous = ["forward-empty"]; previous = ["forward-empty"];
} else { } else {
@@ -909,8 +1204,33 @@ export class Scene {
["camera-matrices", "cameraMatrices"], ["camera-matrices", "cameraMatrices"],
]) ])
addBuffer({ id, array, usage: ["storage"] }); addBuffer({ id, array, usage: ["storage"] });
const forwardPipelines = new Set<string>(); if (!compositeStudioBackground) {
let firstRender = true; 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<string, string>();
let firstColor = true;
let firstDepth = true;
let sceneColorReady = false;
let forwardTarget = compositeSceneColor ? "scene-color" : "hdr";
for (const mesh of renderedMeshes) { for (const mesh of renderedMeshes) {
const vertex = `geometry-${mesh.geometryId}-positions`; const vertex = `geometry-${mesh.geometryId}-positions`;
addBuffer({ addBuffer({
@@ -921,14 +1241,20 @@ export class Scene {
}); });
const normal = `geometry-${mesh.geometryId}-normals`; const normal = `geometry-${mesh.geometryId}-normals`;
const uv = `geometry-${mesh.geometryId}-uvs`; const uv = `geometry-${mesh.geometryId}-uvs`;
const uv2 = `geometry-${mesh.geometryId}-uvs2`;
const tangent = `geometry-${mesh.geometryId}-tangents`; const tangent = `geometry-${mesh.geometryId}-tangents`;
const color = `geometry-${mesh.geometryId}-colors`;
const hasNormals = !!this.geometryData(mesh.geometryId, "normals"); const hasNormals = !!this.geometryData(mesh.geometryId, "normals");
const hasUvs = !!this.geometryData(mesh.geometryId, "uvs"); const hasUvs = !!this.geometryData(mesh.geometryId, "uvs");
const hasUvs2 = !!this.geometryData(mesh.geometryId, "uvs2");
const hasTangents = !!this.geometryData(mesh.geometryId, "tangents"); const hasTangents = !!this.geometryData(mesh.geometryId, "tangents");
const hasColors = !!this.geometryData(mesh.geometryId, "colors");
for (const [present, id, kind] of [ for (const [present, id, kind] of [
[hasNormals, normal, "normals"], [hasNormals, normal, "normals"],
[hasUvs, uv, "uvs"], [hasUvs, uv, "uvs"],
[hasUvs2, uv2, "uvs2"],
[hasTangents, tangent, "tangents"], [hasTangents, tangent, "tangents"],
[hasColors, color, "colors"],
] as const) ] as const)
if (present) if (present)
addBuffer({ addBuffer({
@@ -977,21 +1303,91 @@ export class Scene {
pointers[lane] pointers[lane]
? this.#textures.get(Number(pointers[lane]) - 1) ? this.#textures.get(Number(pointers[lane]) - 1)
: undefined; : undefined;
const baseTexture = texture(0); const materialTextures = Array.from({ length: 13 }, (_, lane) =>
const materialTexture = texture(1); lane === 2 && !hasTangents ? undefined : texture(lane),
const normalTexture = hasTangents ? texture(2) : undefined; );
const normalTexture = materialTextures[2];
const detailed = hasNormals && hasUvs; const detailed = hasNormals && hasUvs;
const mask = detailed const flags = Math.round(this.array("materials").row(material)[11]);
? (baseTexture ? 1 : 0) | const extensionInfo = this.array("materialExtensions").row(material);
(materialTexture ? 2 : 0) | const transmitted = extensionInfo[0] > 0;
(normalTexture ? 4 : 0) let mask = detailed
? materialTextures.reduce(
(value, current, lane) =>
value | (current === undefined ? 0 : 1 << lane),
0,
)
: 0; : 0;
const pipeline = detailed ? `forward-pbr-${mask}` : "forward-basic"; if (!transmitted) mask &= ~((1 << 5) | (1 << 6));
if (!forwardPipelines.has(pipeline)) { else if (extensionInfo[2] <= 0) mask &= ~(1 << 6);
forwardPipelines.add(pipeline); 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({ renderPipelines.push({
id: pipeline, id: pipeline,
code: detailed ? pbrShader(mask) : basicForwardShader, code,
vertex: { vertex: {
entry: "vertex", entry: "vertex",
buffers: detailed 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: { fragment: {
entry: "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: { depthStencil: {
format: "depth24plus", format: "depth24plus",
depth_write_enabled: true, depth_write_enabled: (flags & 3) !== 2 && !transmitted,
depth_compare: "less", depth_compare: "less",
}, },
primitive: {
topology: "triangle-list",
frontFace: "ccw",
cullMode: (flags & 4) !== 0 ? null : "back",
conservative: false,
},
}); });
} }
const instance = const instance = (mesh.id + (material + 1) * 65536) >>> 0;
(mesh.id + const samplerTexture = materialTextures.find(
(draw.material === undefined (value, lane) => value !== undefined && (mask & (1 << lane)) !== 0,
? 0 );
: (draw.material + 1) * 65536)) >>>
0;
passes.push({ passes.push({
id, id,
type: "render", type: "render",
@@ -1099,48 +1547,45 @@ export class Scene {
: []), : []),
...(detailed && mask ...(detailed && mask
? [ ? [
{ group: 1, binding: 0, resource: "material-linear" }, {
...(baseTexture group: 1,
binding: 0,
resource: samplerTexture?.sampler
? `${samplerTexture.id}-sampler`
: "material-linear",
},
...materialTextures.flatMap((value, lane) =>
value && (mask & (1 << lane)) !== 0
? [ ? [
{ {
group: 1, group: 1,
binding: 1, binding: lane + 1,
resource: baseTexture.id, resource: value.id,
}, },
] ]
: [],
),
]
: []), : []),
...(materialTexture ...(detailed && transmitted
? [ ? [
{ { group: 2, binding: 0, resource: "scene-color" },
group: 1, { group: 2, binding: 1, resource: "linear" },
binding: 2,
resource: materialTexture.id,
},
]
: []),
...(normalTexture
? [
{
group: 1,
binding: 3,
resource: normalTexture.id,
},
]
: []),
] ]
: []), : []),
], ],
color: [ color: [
{ {
resource: "hdr", resource: forwardTarget,
...(firstRender ...((firstColor && compositeStudioBackground) ||
? { clear: [0.015, 0.025, 0.05, 1] } clearCompositeLayer
? { clear: [0, 0, 0, 0] }
: { load: "load" }), : { load: "load" }),
}, },
], ],
depth: { depth: {
resource: "depth", resource: "depth",
...(firstRender ? { clear: 1 } : { load: "load" }), ...(firstDepth ? { clear: 1 } : { load: "load" }),
}, },
vertexBuffers: [ vertexBuffers: [
{ slot: 0, resource: vertex }, { slot: 0, resource: vertex },
@@ -1148,7 +1593,13 @@ export class Scene {
? [ ? [
{ slot: 1, resource: normal }, { slot: 1, resource: normal },
{ slot: 2, resource: uv }, { 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 }, : { vertices: draw.count, instances: 1, firstInstance: instance },
}); });
firstRender = false; firstColor = false;
firstDepth = false;
previous = [id]; previous = [id];
} }
} }
@@ -1237,7 +1689,14 @@ export class Scene {
); );
renderPipelines.push({ renderPipelines.push({
id: "present", id: "present",
code: presentShader(toneMap), code: presentShader(
toneMap,
compositeStudioBackground
? "studio"
: compositeSceneColor
? "scene"
: undefined,
),
vertex: { entry: "vertex" }, vertex: { entry: "vertex" },
fragment: { entry: "fragment", targets: [{ format: "canvas" }] }, fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
}); });
@@ -1249,6 +1708,9 @@ export class Scene {
bindings: [ bindings: [
{ group: 0, binding: 0, resource: input }, { group: 0, binding: 0, resource: input },
{ group: 0, binding: 1, resource: "linear" }, { group: 0, binding: 1, resource: "linear" },
...(compositeSceneColor
? [{ group: 0, binding: 2, resource: "scene-color" }]
: []),
], ],
color: [{ resource: "canvas", clear: [0, 0, 0, 1] }], color: [{ resource: "canvas", clear: [0, 0, 0, 1] }],
draw: { vertices: 3 }, draw: { vertices: 3 },
+51 -28
View File
@@ -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) { set alpha(value: number) {
const row = this.cameraRow(); 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) { set beta(value: number) {
const row = this.cameraRow(); const row = this.cameraRow();
this.#updateTransform(() => { this.#updateTransform(() => {
row[14] = Math.min(Math.PI - 0.001, Math.max(0.001, value)); 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) { set radius(value: number) {
const row = this.cameraRow(); 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) { 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; this.#target = value;
const row = this.cameraRow(); 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) { attachControls(controls: ArcRotateControls) {
@@ -80,9 +95,12 @@ export class ArcRotateCamera extends Camera {
controls.element.addEventListener("pointerup", this.#up); controls.element.addEventListener("pointerup", this.#up);
controls.element.addEventListener("pointercancel", this.#up); controls.element.addEventListener("pointercancel", this.#up);
controls.element.addEventListener("contextmenu", this.#contextMenu); 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; return this;
} }
@@ -96,7 +114,7 @@ export class ArcRotateCamera extends Camera {
element.removeEventListener("contextmenu", this.#contextMenu); element.removeEventListener("contextmenu", this.#contextMenu);
element.removeEventListener("wheel", this.#wheel); element.removeEventListener("wheel", this.#wheel);
} }
cancelAnimationFrame(this.#frame); if (this.#frame) cancelAnimationFrame(this.#frame);
this.#frame = 0; this.#frame = 0;
this.#pointer = undefined; this.#pointer = undefined;
this.#controls = undefined; this.#controls = undefined;
@@ -106,11 +124,7 @@ export class ArcRotateCamera extends Camera {
const row = this.cameraRow(); const row = this.cameraRow();
if (!this.#target) return row.subarray(16, 19); if (!this.#target) return row.subarray(16, 19);
const position = this.#target.position; const position = this.#target.position;
return [ return [position.x + row[16], position.y + row[17], position.z + row[18]];
position.x + row[16],
position.y + row[17],
position.z + row[18],
];
} }
#updateTransform(change?: () => void) { #updateTransform(change?: () => void) {
@@ -130,10 +144,16 @@ export class ArcRotateCamera extends Camera {
#down = (event: PointerEvent) => { #down = (event: PointerEvent) => {
event.preventDefault(); 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); this.#controls?.element.setPointerCapture?.(event.pointerId);
}; };
#up = () => { this.#pointer = undefined; }; #up = () => {
this.#pointer = undefined;
};
#contextMenu = (event: Event) => event.preventDefault(); #contextMenu = (event: Event) => event.preventDefault();
#move = (event: PointerEvent) => { #move = (event: PointerEvent) => {
if (!this.#pointer || !this.#controls) return; if (!this.#pointer || !this.#controls) return;
@@ -144,9 +164,17 @@ export class ArcRotateCamera extends Camera {
if (this.#pointer.button === 2) { if (this.#pointer.button === 2) {
const row = this.cameraRow(); const row = this.cameraRow();
this.#updateTransform(() => { this.#updateTransform(() => {
const speed = this.#controls?.panSpeed ?? 0.005; const element = this.#controls!.element;
const horizontal = -x * speed; const verticalExtent =
const vertical = y * speed; 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 sinAlpha = Math.sin(row[13]);
const cosAlpha = Math.cos(row[13]); const cosAlpha = Math.cos(row[13]);
const sinBeta = Math.sin(row[14]); 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[13] += (pad.axes[2] ?? pad.axes[0] ?? 0) * 0.03;
row[14] = Math.min( row[14] = Math.min(
Math.PI - 0.001, Math.PI - 0.001,
Math.max( Math.max(0.001, row[14] + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03),
0.001,
row[14] + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03,
),
); );
row[15] = Math.max( row[15] = Math.max(
0.01, 0.01,
row[15] + row[15] +
((pad.buttons[6]?.value ?? 0) - ((pad.buttons[6]?.value ?? 0) - (pad.buttons[7]?.value ?? 0)) * 0.1,
(pad.buttons[7]?.value ?? 0)) *
0.1,
); );
}); });
} }
+32 -4
View File
@@ -1,5 +1,8 @@
import { Mesh } from "../Mesh"; import { Mesh } from "../Mesh";
import type { Scene } from "../Scene"; 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 { PBRMaterial } from "../materials/PBRMaterial";
import { Texture } from "../materials/Texture"; import { Texture } from "../materials/Texture";
@@ -49,12 +52,20 @@ export async function importGltf(scene: Scene, url: string | URL) {
for (const material of result.materials) { for (const material of result.materials) {
if (material.baseColorTexture >= 0) srgb.add(material.baseColorTexture); if (material.baseColorTexture >= 0) srgb.add(material.baseColorTexture);
if (material.emissiveTexture >= 0) srgb.add(material.emissiveTexture); 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( textures = result.textures.map(
({ image }: { image: ImageBitmap }, index: number) => (
{ image, sampler }: { image: ImageBitmap; sampler: any },
index: number,
) =>
new Texture(scene, { new Texture(scene, {
source: image, source: image,
format: srgb.has(index) ? "rgba8unorm-srgb" : "rgba8unorm", format: srgb.has(index) ? "rgba8unorm-srgb" : "rgba8unorm",
sampler,
}), }),
); );
await Promise.all(textures.map((texture: Texture) => texture.ready)); 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, { new PBRMaterial(scene, {
...options, ...options,
baseColorTexture: textures[options.baseColorTexture], baseColorTexture: textures[options.baseColorTexture],
metallicRoughnessTexture: metallicRoughnessTexture: textures[options.metallicRoughnessTexture],
textures[options.metallicRoughnessTexture],
normalTexture: textures[options.normalTexture], normalTexture: textures[options.normalTexture],
emissiveTexture: textures[options.emissiveTexture], 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)); 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.normals ? { normals: primitive.normals } : {}),
...(primitive.tangents ? { tangents: primitive.tangents } : {}), ...(primitive.tangents ? { tangents: primitive.tangents } : {}),
...(primitive.uvs ? { uvs: primitive.uvs } : {}), ...(primitive.uvs ? { uvs: primitive.uvs } : {}),
...(primitive.uvs2 ? { uvs2: primitive.uvs2 } : {}),
...(primitive.colors ? { colors: primitive.colors } : {}), ...(primitive.colors ? { colors: primitive.colors } : {}),
}, },
}), }),
); );
await Promise.all(meshes.map((mesh) => mesh.ready)); 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( await Promise.all(
result.textures.map((_: unknown, index: number) => result.textures.map((_: unknown, index: number) =>
+719 -135
View File
@@ -1,7 +1,15 @@
export {}; export {};
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const widths: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }; const widths: Record<string, number> = {
SCALAR: 1,
VEC2: 2,
VEC3: 3,
VEC4: 4,
MAT2: 4,
MAT3: 9,
MAT4: 16,
};
const sizes: Record<number, number> = { const sizes: Record<number, number> = {
5120: 1, 5120: 1,
5121: 1, 5121: 1,
@@ -29,79 +37,165 @@ function component(view: DataView, offset: number, type: number) {
throw new Error("GLTF_COMPONENT"); throw new Error("GLTF_COMPONENT");
} }
function rotate(rotor: number[], value: number[]) { const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
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,
];
}
function multiply(a: number[], b: number[]) { function multiply(a: ArrayLike<number>, b: ArrayLike<number>) {
return [ const result = new Array<number>(16);
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], for (let column = 0; column < 4; column++)
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], for (let row = 0; row < 4; row++) {
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], let value = 0;
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], for (let lane = 0; lane < 4; lane++)
]; value += a[lane * 4 + row] * b[column * 4 + lane];
} result[column * 4 + row] = value;
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 }; return result;
} }
function transform(node: any) { function transform(node: any) {
return node.matrix if (node.matrix) return Array.from(node.matrix);
? decompose(node.matrix) const [x, y, z, w] = node.rotation ?? [0, 0, 0, 1];
: { const [sx, sy, sz] = node.scale ?? [1, 1, 1];
position: node.translation ?? [0, 0, 0], const [tx, ty, tz] = node.translation ?? [0, 0, 0];
rotor: node.rotation ?? [0, 0, 0, 1], const xx = x * x;
scale: node.scale ?? [1, 1, 1], 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) { function determinant(matrix: ArrayLike<number>) {
const position = rotate( return (
parent.rotor, matrix[0] * (matrix[5] * matrix[10] - matrix[6] * matrix[9]) -
local.position.map( matrix[4] * (matrix[1] * matrix[10] - matrix[2] * matrix[9]) +
(value: number, lane: number) => value * parent.scale[lane], 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), function point(matrix: ArrayLike<number>, source: ArrayLike<number>) {
scale: local.scale.map( return [
(value: number, lane: number) => value * parent.scale[lane], 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<number>, source: ArrayLike<number>) {
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<number>, source: ArrayLike<number>) {
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<number>) {
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) { function parse(bytes: Uint8Array) {
@@ -127,7 +221,127 @@ function parse(bytes: Uint8Array) {
return { document, binary }; 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); const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP_${response.status}`); if (!response.ok) throw new Error(`HTTP_${response.status}`);
const { document, binary } = parse( const { document, binary } = parse(
@@ -159,37 +373,75 @@ async function load(url: string) {
return result.blob(); 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( const textures = await Promise.all(
(document.textures ?? []).map(async (texture: any) => ({ (document.textures ?? []).map(async (texture: any) => ({
image: await createImageBitmap(imageBlobs[texture.source]), 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 accessor = (id: number, integer = false) => {
const source = document.accessors[id]; const source = document.accessors[id];
const width = widths[source.type]; const width = widths[source.type];
const size = sizes[source.componentType]; const size = sizes[source.componentType];
const values = integer
? new Uint32Array(source.count * width)
: new Float32Array(source.count * width);
if (source.bufferView !== undefined) {
const bufferView = document.bufferViews[source.bufferView]; const bufferView = document.bufferViews[source.bufferView];
const bytes = buffers[bufferView.buffer]; const bytes = buffers[bufferView.buffer];
const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0); const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0);
const stride = bufferView.byteStride ?? width * size; const stride = bufferView.byteStride ?? width * size;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const view = new DataView(
const values = integer bytes.buffer,
? new Uint32Array(source.count * width) bytes.byteOffset,
: new Float32Array(source.count * width); bytes.byteLength,
);
if ( if (
!source.sparse &&
!source.normalized && !source.normalized &&
stride === width * size && stride === width * size &&
(bytes.byteOffset + start) % size === 0 (bytes.byteOffset + start) % size === 0
) { ) {
const packed = new arrays[source.componentType]( values.set(
new arrays[source.componentType](
bytes.buffer, bytes.buffer,
bytes.byteOffset + start, bytes.byteOffset + start,
source.count * width, source.count * width,
),
); );
values.set(packed); } else
return values;
}
for (let item = 0; item < source.count; item++) for (let item = 0; item < source.count; item++)
for (let lane = 0; lane < width; lane++) { for (let lane = 0; lane < width; lane++) {
let value = component( let value = component(
@@ -197,105 +449,437 @@ async function load(url: string) {
start + item * stride + lane * size, start + item * stride + lane * size,
source.componentType, source.componentType,
); );
if (!integer && source.normalized) { if (!integer && source.normalized)
const maximum = value = normalized(value, source.componentType);
source.componentType === 5121
? 255
: source.componentType === 5123
? 65535
: 1;
value /= maximum;
}
values[item * width + lane] = value; 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;
}
}
}
return values; return values;
}; };
const materials = (document.materials ?? []).map((material: any) => { const textureInfo = (info: any) => {
const pbr = material.pbrMetallicRoughness ?? {}; if (!info) return undefined;
const extension = info.extensions?.KHR_texture_transform ?? {};
return { return {
baseColor: pbr.baseColorFactor ?? [1, 1, 1, 1], texture: info.index,
metallic: pbr.metallicFactor ?? 0, texCoord: extension.texCoord ?? info.texCoord ?? 0,
roughness: pbr.roughnessFactor ?? 0.7, 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], emissive: material.emissiveFactor ?? [0, 0, 0],
normalScale: material.normalTexture?.scale ?? 1,
alphaCutoff: material.alphaCutoff ?? 0.5, alphaCutoff: material.alphaCutoff ?? 0.5,
baseColorTexture: pbr.baseColorTexture?.index ?? -1, flags,
metallicRoughnessTexture: pbr.metallicRoughnessTexture?.index ?? -1, occlusionStrength: material.occlusionTexture?.strength ?? 1,
normalTexture: material.normalTexture?.index ?? -1, transmission: transmission.transmissionFactor ?? 0,
emissiveTexture: material.emissiveTexture?.index ?? -1, 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<number, number>();
for (let id = 0; id < nodes.length; id++)
for (const child of nodes[id].children ?? []) parents.set(child, id);
const worlds = new Map<number, number[]>();
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 primitives: any[] = [];
const emitMesh = (meshId: number, transform: any) => { const emitMesh = (meshId: number, nodeId: number, matrix: number[]) => {
const mesh = document.meshes?.[meshId]; 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 ?? []) { for (const primitive of mesh?.primitives ?? []) {
if ( if (
(primitive.mode ?? 4) !== 4 || (primitive.mode ?? 4) !== 4 ||
primitive.attributes.POSITION === undefined primitive.attributes.POSITION === undefined
) )
continue; 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 = const indices =
primitive.indices === undefined primitive.indices === undefined
? Uint32Array.from( ? Uint32Array.from(
{ length: positions.length / 3 }, { length: positions.length / 3 },
(_, index) => index, (_, 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({ primitives.push({
positions, positions,
normals,
tangents,
uvs,
uvs2,
indices, indices,
...(primitive.attributes.NORMAL === undefined ...(colorAccessor === undefined
? {} ? {}
: { normals: accessor(primitive.attributes.NORMAL) }), : {
...(primitive.attributes.TANGENT === undefined colors: color4(
? {} accessor(colorAccessor) as Float32Array,
: { tangents: accessor(primitive.attributes.TANGENT) }), widths[document.accessors[colorAccessor].type],
...(primitive.attributes.TEXCOORD_0 === undefined ),
? {} }),
: { uvs: accessor(primitive.attributes.TEXCOORD_0) }),
...(primitive.attributes.COLOR_0 === undefined
? {}
: { colors: accessor(primitive.attributes.COLOR_0) }),
material: primitive.material ?? -1, material: primitive.material ?? -1,
...transform, position: [0, 0, 0],
rotor: [0, 0, 0, 1],
scale: [1, 1, 1],
}); });
} }
}; };
const nodes = document.nodes ?? []; const emitNode = (id: number) => {
const scene = document.scenes?.[document.scene ?? 0]; const node = nodes[id];
const children = new Set(nodes.flatMap((node: any) => node.children ?? [])); if (node.mesh === undefined) return;
const roots = const instances = node.extensions?.EXT_mesh_gpu_instancing?.attributes;
scene?.nodes ?? if (!instances) return emitMesh(node.mesh, id, world(id));
nodes const translations =
.map((_: any, id: number) => id) instances.TRANSLATION === undefined
.filter((id: number) => !children.has(id)); ? undefined
const visit = ( : (accessor(instances.TRANSLATION) as Float32Array);
id: number, const rotations =
parent = { instances.ROTATION === undefined
position: [0, 0, 0], ? undefined
rotor: [0, 0, 0, 1], : (accessor(instances.ROTATION) as Float32Array);
scale: [1, 1, 1], const scales =
}, instances.SCALE === undefined
) => { ? undefined
const node = nodes[id] ?? {}; : (accessor(instances.SCALE) as Float32Array);
const world = compose(parent, transform(node)); const count = translations
if (node.mesh !== undefined) emitMesh(node.mesh, world); ? translations.length / 3
for (const child of node.children ?? []) visit(child, world); : 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) if (!nodes.length)
for (let id = 0; id < (document.meshes ?? []).length; id++) for (let id = 0; id < (document.meshes ?? []).length; id++)
emitMesh(id, {}); emitMesh(id, -1, identity);
return { materials, primitives, textures }; 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 }) => { if (typeof addEventListener === "function")
addEventListener("message", async ({ data }) => {
try { try {
const result = await load(data.url); const result = await loadGltf(data.url);
const transfers = result.primitives.flatMap((primitive: any) => const transfers = result.primitives.flatMap((primitive: any) =>
["positions", "indices", "normals", "tangents", "uvs", "colors"] ["positions", "indices", "normals", "tangents", "uvs", "uvs2", "colors"]
.map((name) => primitive[name]?.buffer) .map((name) => primitive[name]?.buffer)
.filter(Boolean), .filter(Boolean),
); );
@@ -307,4 +891,4 @@ addEventListener("message", async ({ data }) => {
error: error instanceof Error ? error.message : "GLTF_IMPORT", error: error instanceof Error ? error.message : "GLTF_IMPORT",
}); });
} }
}); });
+157 -15
View File
@@ -1,6 +1,13 @@
import type { Scene } from "../Scene"; import type { Scene } from "../Scene";
import type { Texture } from "./Texture"; import type { Texture } from "./Texture";
export type PBRTextureInfo = {
texCoord?: number;
offset?: ArrayLike<number>;
scale?: ArrayLike<number>;
rotation?: number;
};
export type PBRMaterialOptions = { export type PBRMaterialOptions = {
baseColor?: ArrayLike<number>; baseColor?: ArrayLike<number>;
metallic?: number; metallic?: number;
@@ -12,9 +19,40 @@ export type PBRMaterialOptions = {
metallicRoughnessTexture?: Texture; metallicRoughnessTexture?: Texture;
normalTexture?: Texture; normalTexture?: Texture;
emissiveTexture?: Texture; emissiveTexture?: Texture;
occlusionTexture?: Texture;
transmissionTexture?: Texture;
thicknessTexture?: Texture;
clearcoatTexture?: Texture;
iridescenceTexture?: Texture;
iridescenceThicknessTexture?: Texture;
anisotropyTexture?: Texture;
specularTexture?: Texture;
specularColorTexture?: Texture;
textureInfos?: ArrayLike<PBRTextureInfo | undefined>;
flags?: number;
occlusionStrength?: number;
transmission?: number;
ior?: number;
thickness?: number;
attenuationDistance?: number;
attenuationColor?: ArrayLike<number>;
emissiveStrength?: number;
clearcoat?: number;
clearcoatRoughness?: number;
specular?: number;
sheenColor?: ArrayLike<number>;
sheenRoughness?: number;
specularColor?: ArrayLike<number>;
iridescence?: number;
iridescenceIor?: number;
iridescenceThicknessMinimum?: number;
iridescenceThicknessMaximum?: number;
anisotropy?: number;
anisotropyRotation?: number;
specularExtensionColor?: ArrayLike<number>;
}; };
/** Conventional PBR values and texture IDs stored in two shared SOA rows. */ /** Conventional PBR values, extensions, texture IDs, and UV transforms. */
export class PBRMaterial { export class PBRMaterial {
readonly scene: Scene; readonly scene: Scene;
id = -1; id = -1;
@@ -28,48 +66,152 @@ export class PBRMaterial {
options.metallicRoughnessTexture, options.metallicRoughnessTexture,
options.normalTexture, options.normalTexture,
options.emissiveTexture, 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); ].filter((texture): texture is Texture => texture !== undefined);
this.ready = Promise.all(textures.map((texture) => texture.ready)).then(async () => { this.ready = Promise.all(textures.map((texture) => texture.ready)).then(
async () => {
const id = await scene.allocateMaterial(); const id = await scene.allocateMaterial();
this.id = id; this.id = id;
const color = Array.from(options.baseColor ?? [1, 1, 1, 1]); const color = Array.from(options.baseColor ?? [1, 1, 1, 1]);
const emissive = Array.from(options.emissive ?? [0, 0, 0]); const emissive = Array.from(options.emissive ?? [0, 0, 0]);
if (color.length !== 4 || emissive.length !== 3) throw new TypeError("PBR material vectors"); const attenuation = Array.from(options.attenuationColor ?? [1, 1, 1]);
scene.array("materials").write(id, [ 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, ...color,
options.metallic ?? 0, options.metallic ?? 0,
options.roughness ?? 0.7, options.roughness ?? 0.7,
...emissive, ...emissive,
options.normalScale ?? 1, options.normalScale ?? 1,
options.alphaCutoff ?? 0.5, options.alphaCutoff ?? 0.5,
0, options.flags ?? 0,
]); ]);
scene.array("materialTextures").write(id, [ scene
.array("materialTextures")
.write(id, [
(options.baseColorTexture?.id ?? -1) + 1, (options.baseColorTexture?.id ?? -1) + 1,
(options.metallicRoughnessTexture?.id ?? -1) + 1, (options.metallicRoughnessTexture?.id ?? -1) + 1,
(options.normalTexture?.id ?? -1) + 1, (options.normalTexture?.id ?? -1) + 1,
(options.emissiveTexture?.id ?? -1) + 1, (options.emissiveTexture?.id ?? -1) + 1,
0, 0, 0, 0, (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; return this;
}); },
);
} }
#values() { #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); 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<number>) { set baseColor(value: ArrayLike<number>) {
if (value.length !== 4) throw new RangeError("baseColor"); if (value.length !== 4) throw new RangeError("baseColor");
this.#values().set(value, 0); this.#values().set(value, 0);
} }
get metallic() { return this.#values()[4]; } get metallic() {
set metallic(value: number) { this.#values()[4] = value; } return this.#values()[4];
get roughness() { return this.#values()[5]; } }
set roughness(value: number) { this.#values()[5] = value; } set metallic(value: number) {
get emissive() { return this.#values().subarray(6, 9); } 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<number>) { set emissive(value: ArrayLike<number>) {
if (value.length !== 3) throw new RangeError("emissive"); if (value.length !== 3) throw new RangeError("emissive");
this.#values().set(value, 6); this.#values().set(value, 6);
+3
View File
@@ -1,4 +1,5 @@
import type { Scene } from "../Scene"; import type { Scene } from "../Scene";
import type { GraphSampler } from "../ComputePass";
export type TextureOptions = { export type TextureOptions = {
source?: string | ImageBitmap; source?: string | ImageBitmap;
@@ -7,6 +8,7 @@ export type TextureOptions = {
usage?: string[]; usage?: string[];
mipmaps?: boolean; mipmaps?: boolean;
transient?: boolean; transient?: boolean;
sampler?: Omit<GraphSampler, "id">;
}; };
let nextTextureName = 1; let nextTextureName = 1;
@@ -52,6 +54,7 @@ export class Texture {
]), ]),
], ],
transient: options.transient ?? false, transient: options.transient ?? false,
sampler: options.sampler,
}); });
this.id = registration.number; this.id = registration.number;
await registration.ready; await registration.ready;
+220
View File
@@ -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<number>) {
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<string, unknown> = {};
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<string, SharedRows>();
#nextObjects = new Map<string, number>();
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;
}
}
+247
View File
@@ -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<number>, expected: ArrayLike<number>) {
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<string, Set<(event: any) => 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<string, unknown>) => {
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();
});
+145
View File
@@ -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<number>, expected: ArrayLike<number>) {
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<string, (event: any) => 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,
});
}
});
+359
View File
@@ -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<number>, expected: ArrayLike<number>) {
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, []);
});
+46
View File
@@ -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 });
}
+275
View File
@@ -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);
});
+53
View File
@@ -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<string, ArrayLike<number>> = triangle,
) {
return new Mesh(scene, { material, vertexData });
}
+18
View File
@@ -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
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+391
View File
@@ -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 },
);
}
+16
View File
@@ -14,6 +14,7 @@
"devDependencies": { "devDependencies": {
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"agent-browser": "0.33.2",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"esbuild": "^0.25.9", "esbuild": "^0.25.9",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
@@ -1462,6 +1463,21 @@
"resolved": "addons/handles", "resolved": "addons/handles",
"link": true "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": { "node_modules/algoliasearch": {
"version": "5.57.0", "version": "5.57.0",
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.57.0.tgz", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.57.0.tgz",
+5 -1
View File
@@ -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}", "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", "server": "go run ./server",
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}", "coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
"build:coredocs": "vitepress build coredocs" "build:coredocs": "vitepress build coredocs",
"test": "npm run test:handles",
"test:handles": "npm test --workspace @yawn/handles",
"test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles"
}, },
"devDependencies": { "devDependencies": {
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"agent-browser": "0.33.2",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"esbuild": "^0.25.9", "esbuild": "^0.25.9",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
+3 -1
View File
@@ -35,7 +35,7 @@ func newApplication(store *playgroundStore, packages map[string]memoryAsset, mon
return nil, err return nil, err
} }
pages := make(map[string][]byte) 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) content, err := webFiles.ReadFile("web/" + name)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -63,6 +63,8 @@ func (app *application) routes() http.Handler {
mux.HandleFunc("/docs/", app.servePage("docs.html")) mux.HandleFunc("/docs/", app.servePage("docs.html"))
mux.HandleFunc("/playground", app.servePlayground) mux.HandleFunc("/playground", app.servePlayground)
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) mux.HandleFunc("/", app.serveHome)
return app.headers(mux) return app.headers(mux)
} }
+21
View File
@@ -144,3 +144,24 @@ func TestSavedPlaygroundPageRoute(t *testing.T) {
t.Fatalf("content type = %q", contentType) 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)
}
}
}
+185
View File
@@ -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;
}
}
+309
View File
@@ -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();
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#06111d" />
<meta
name="description"
content="Open a local glTF or GLB file in the Yawn WebGPU viewer."
/>
<title>glTF Viewer — Yawn</title>
<link rel="stylesheet" href="/assets/gltf.css" />
<script type="importmap">
{
"imports": {
"@yawn/core": "/pkg/core.js",
"@yawn/handles": "/pkg/handles.js"
}
}
</script>
<script type="module" src="/assets/gltf.js"></script>
</head>
<body>
<main id="drop-zone" class="viewer" aria-label="Yawn glTF viewer">
<canvas id="canvas" aria-label="3D model preview"></canvas>
<output id="render-time" class="render-time" hidden>
<span>Render</span>
<strong id="render-value">— ms</strong>
</output>
<button id="drop-prompt" class="drop-prompt" type="button">
<span class="drop-icon" aria-hidden="true">↓</span>
<strong id="prompt-message">Drop a glTF file</strong>
<small id="prompt-detail"
>GLB or self-contained glTF · processed locally</small
>
</button>
<input
id="file-picker"
type="file"
accept=".glb,.gltf,model/gltf-binary,model/gltf+json"
hidden
/>
</main>
</body>
</html>