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
+4
View File
@@ -4,6 +4,10 @@
"description": "Conventional scene handles over Yawn's worker and shared render data",
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"test": "node test/run.mjs",
"test:screenshots": "node --test --test-concurrency=1 test/screenshots/run.mjs"
},
"dependencies": {
"@yawn/core": "0.1.0"
}
+55 -17
View File
@@ -2,7 +2,15 @@ import { Node, type NodeOptions } from "./Node";
import type { Scene } from "./Scene";
import type { PBRMaterial } from "./materials/PBRMaterial";
export const VertexKinds = Object.freeze(["positions", "normals", "tangents", "uvs", "colors", "indices"] as const);
export const VertexKinds = Object.freeze([
"positions",
"normals",
"tangents",
"uvs",
"uvs2",
"colors",
"indices",
] as const);
export type VertexKind = (typeof VertexKinds)[number];
export type MeshOptions = NodeOptions & {
geometryId?: number;
@@ -27,15 +35,22 @@ export class Mesh extends Node {
this.ready = nodeReady.then(async () => {
if (options.material) await options.material.ready;
await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32");
this.vertexCount = Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) / 3;
this.indexCount = Number(scene.geometryData(this.geometryId, "indices")?.length ?? 0);
this.instanceOf = options.geometryId === undefined ? this.id : this.geometryId;
scene.array("meshInfo").write(this.id, [
this.geometryId,
options.material?.id ?? 0,
options.visible === false ? 0 : 1,
this.instanceOf,
]);
this.vertexCount =
Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) /
3;
this.indexCount = Number(
scene.geometryData(this.geometryId, "indices")?.length ?? 0,
);
this.instanceOf =
options.geometryId === undefined ? this.id : this.geometryId;
scene
.array("meshInfo")
.write(this.id, [
this.geometryId,
options.material?.id ?? 0,
options.visible === false ? 0 : 1,
this.instanceOf,
]);
for (const [kind, data] of Object.entries(options.vertexData ?? {}))
await this.#setVertexData(kind as VertexKind, data!, false);
this.#writeBounds();
@@ -60,7 +75,8 @@ export class Mesh extends Node {
}
set material(value: PBRMaterial) {
if (value.scene !== this.scene || value.id < 0) throw new Error("Await a material from the same Scene");
if (value.scene !== this.scene || value.id < 0)
throw new Error("Await a material from the same Scene");
this.scene.markDirty(true);
this.scene.array("meshInfo").row(this.id)[1] = value.id;
void this.scene.updateRenderGraph().catch(() => undefined);
@@ -72,13 +88,18 @@ export class Mesh extends Node {
}
async setVertexData(kind: VertexKind, data: ArrayLike<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.#setVertexData(kind, data, true);
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) {
const original = this.geometryId;
this.geometryId = await this.scene.cloneGeometry(original);
@@ -86,7 +107,15 @@ export class Mesh extends Node {
this.scene.referenceGeometry(this.geometryId);
this.instanceOf = this.id;
this.scene.markDirty(true);
this.scene.array("meshInfo").row(this.id).set([this.geometryId, this.materialId, this.isVisible ? 1 : 0, this.id]);
this.scene
.array("meshInfo")
.row(this.id)
.set([
this.geometryId,
this.materialId,
this.isVisible ? 1 : 0,
this.id,
]);
}
if (kind === "positions") this.vertexCount = data.length / 3;
if (kind === "indices") this.indexCount = data.length;
@@ -96,10 +125,16 @@ export class Mesh extends Node {
async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) {
await Promise.all([this.ready, material.ready]);
if (material.scene !== this.scene) throw new Error("Material must belong to the same Scene");
if (material.scene !== this.scene)
throw new Error("Material must belong to the same Scene");
const list = Array.isArray(faces) ? faces : [faces];
const maximum = Math.max(...list);
const array = await this.scene.ensureRows(`mesh.${this.id}.faceMaterials`, maximum + 1, 16, "u32");
const array = await this.scene.ensureRows(
`mesh.${this.id}.faceMaterials`,
maximum + 1,
16,
"u32",
);
this.scene.markDirty(true);
for (const face of list) {
if (!Number.isInteger(face) || face < 0) throw new RangeError("face");
@@ -120,7 +155,10 @@ export class Mesh extends Node {
minimum[lane] = Math.min(minimum[lane], positions[index + lane]);
maximum[lane] = Math.max(maximum[lane], positions[index + lane]);
}
this.scene.array("bounds").row(this.id).set([...minimum, 0, ...maximum, 0]);
this.scene
.array("bounds")
.row(this.id)
.set([...minimum, 0, ...maximum, 0]);
}
override async dispose() {
+592 -130
View File
@@ -28,6 +28,7 @@ type PostProcessState = {
type TextureState = GraphTexture & {
number: number;
source?: string | ImageBitmap;
sampler?: Omit<GraphSampler, "id">;
};
const rows = [
@@ -40,7 +41,9 @@ const rows = [
["cameras", 80, "f32"],
["cameraMatrices", 80, "f32"],
["materials", 48, "f32"],
["materialTextures", 32, "u32"],
["materialTextures", 64, "u32"],
["materialExtensions", 112, "f32"],
["materialTextureInfo", 416, "f32"],
["pointLights", 32, "f32"],
["rectAreaLights", 48, "f32"],
["spotLights", 32, "f32"],
@@ -49,6 +52,11 @@ const rows = [
["sceneAccent", 16, "f32"],
] as const;
const compactLightLimit = 500;
const compactPointStart = 11;
const compactSpotCount = compactPointStart + compactLightLimit;
const compactSpotStart = compactSpotCount + 1;
const clusterShader = /* wgsl */ `
@group(0) @binding(0) var<storage, read> pointLights: 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>) {
if (id.x != 0u) { return; }
var count = 0u;
var pointCount = 0u;
var spotCount = 0u;
var light = vec3<f32>(0.0);
var pointLight = vec3<f32>(0.0);
var spotLight = vec3<f32>(0.0);
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) {
let enabled = pointLights[i * 2u + 1u].y;
count += select(0u, 1u, enabled != 0.0);
if (enabled != 0.0) {
if (pointCount < ${compactLightLimit}u) {
clusters[${compactPointStart}u + pointCount] = i;
}
pointCount += 1u;
}
let contribution = pointLights[i * 2u].rgb * pointLights[i * 2u].a * enabled * 0.02;
light += contribution;
pointLight += contribution;
@@ -79,6 +95,12 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) {
let enabled = spotLights[i * 2u + 1u].w;
count += select(0u, 1u, enabled != 0.0);
if (enabled != 0.0) {
if (spotCount < ${compactLightLimit}u) {
clusters[${compactSpotStart}u + spotCount] = i;
}
spotCount += 1u;
}
let contribution = spotLights[i * 2u].rgb * spotLights[i * 2u].a * enabled * 0.02;
light += contribution;
spotLight += contribution;
@@ -102,6 +124,8 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
clusters[7] = bitcast<u32>(spotLight.r);
clusters[8] = bitcast<u32>(spotLight.g);
clusters[9] = bitcast<u32>(spotLight.b);
clusters[10] = pointCount;
clusters[${compactSpotCount}] = spotCount;
}`;
const basicForwardShader = /* wgsl */ `
@@ -162,10 +186,70 @@ fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(color, base.a);
}`;
function pbrShader(mask: number) {
const baseTexture = mask & 1;
const materialTexture = mask & 2;
const normalTexture = mask & 4;
function pbrShader(
mask: number,
flags: number,
hasColors: boolean,
hasUvs2: boolean,
transmission: boolean,
textureInfo: ArrayLike<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 */ `
struct VertexOutput {
@invariant @builtin(position) position: vec4<f32>,
@@ -173,8 +257,9 @@ struct VertexOutput {
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) tangent: vec4<f32>,
@location(4) @interpolate(flat) mesh: u32,
@location(5) @interpolate(flat) material: u32,
@location(4) color: vec4<f32>,
@location(5) uv2: vec2<f32>,
@location(6) @interpolate(flat) material: 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(9) var<storage, read> spotLights: array<vec4<f32>>;
${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""}
${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d<f32>;" : ""}
${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d<f32>;" : ""}
${normalTexture ? "@group(1) @binding(3) var normalTexture: texture_2d<f32>;" : ""}
${names.map((name, slot) => (texture(slot) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d<f32>;` : "")).join("\n")}
${transmission ? "@group(2) @binding(0) var sceneColor: texture_2d<f32>;\n@group(2) @binding(1) var sceneSampler: sampler;" : ""}
fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
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(2) uv: vec2<f32>,
${normalTexture ? "@location(3) localTangent: vec4<f32>," : ""}
${hasColors ? "@location(4) color: vec4<f32>," : ""}
${hasUvs2 ? "@location(5) uv2: vec2<f32>," : ""}
@builtin(instance_index) packed: u32,
) -> VertexOutput {
let instance = packed & 65535u;
@@ -223,79 +309,184 @@ fn vertex(
output.normal = normalize(rotate(rotors[instance], localNormal / scale));
output.uv = uv;
output.tangent = ${normalTexture ? "vec4(normalize(rotate(rotors[instance], localTangent.xyz * scale)), localTangent.w)" : "vec4(1.0, 0.0, 0.0, 1.0)"};
output.mesh = instance;
output.material = packed >> 16u;
output.color = ${hasColors ? "color" : "vec4(1.0)"};
output.uv2 = ${hasUvs2 ? "uv2" : "uv"};
output.material = (packed >> 16u) - 1u;
return output;
}
fn studioEnvironment(direction: vec3<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
fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
let fallback = meshInfo[input.mesh * 4u + 1u];
let material = select(fallback, input.material - 1u, input.material != 0u);
let factor = materials[material * 3u];
let properties = materials[material * 3u + 1u];
let extra = materials[material * 3u + 2u];
let base = factor * ${baseTexture ? "textureSample(baseTexture, materialSampler, input.uv)" : "vec4(1.0)"};
let packedMaterial = ${materialTexture ? "textureSample(materialTexture, materialSampler, input.uv)" : "vec4(1.0)"};
let metallic = clamp(properties.x * ${materialTexture ? "packedMaterial.b" : "1.0"}, 0.0, 1.0);
let roughness = clamp(properties.y * ${materialTexture ? "packedMaterial.g" : "1.0"}, 0.04, 1.0);
fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4<f32> {
let factor = materials[input.material * 3u];
let properties = materials[input.material * 3u + 1u];
let extra = materials[input.material * 3u + 2u];
let extension0 = vec4(${Array.from(extensionInfo).slice(0, 4).map(number).join(", ")});
let extension1 = vec4(${Array.from(extensionInfo).slice(4, 8).map(number).join(", ")});
let extension2 = vec4(${Array.from(extensionInfo).slice(8, 12).map(number).join(", ")});
let extension3 = vec4(${Array.from(extensionInfo).slice(12, 16).map(number).join(", ")});
let extension4 = vec4(${Array.from(extensionInfo).slice(16, 20).map(number).join(", ")});
let extension5 = vec4(${Array.from(extensionInfo).slice(20, 24).map(number).join(", ")});
let extension6 = vec4(${Array.from(extensionInfo).slice(24, 28).map(number).join(", ")});
let base = factor * input.color * ${sample(0)};
${alphaMode === 1 ? "if (base.a < extra.z) { discard; }" : ""}
let packedMaterial = ${sample(1)};
let metallic = clamp(properties.x * ${specularGlossiness ? "0.0" : texture(1) ? "packedMaterial.b" : "1.0"}, 0.0, 1.0);
${anisotropy ? `let anisotropyAmount = clamp(extension5.x * ${sample(10)}.b, 0.0, 1.0);` : ""}
let roughness = clamp(${specularGlossiness ? `1.0 - (1.0 - properties.y) * ${texture(1) ? "packedMaterial.a" : "1.0"}` : `properties.y * ${texture(1) ? "packedMaterial.g" : "1.0"}`}${anisotropy ? " * mix(1.0, 0.7, anisotropyAmount)" : ""}, 0.04, 1.0);
var normal = normalize(input.normal);
${normalTexture ? "let tangent = normalize(input.tangent.xyz - normal * dot(input.tangent.xyz, normal)); let bitangent = cross(normal, tangent) * input.tangent.w; let mapped = textureSample(normalTexture, materialSampler, input.uv).xyz * 2.0 - 1.0; normal = normalize(mat3x3<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 lightDirection = normalize(vec3<f32>(0.4, 0.7, 0.6));
let halfVector = normalize(lightDirection + view);
let nDotL = max(dot(normal, lightDirection), 0.0);
let nDotV = max(dot(normal, view), 0.0001);
let nDotH = max(dot(normal, halfVector), 0.0);
let f0 = mix(vec3(0.04), base.rgb, metallic);
let specular = f0 * pow(nDotH, max(2.0, 2.0 / (roughness * roughness) - 2.0));
let vDotH = max(dot(view, halfVector), 0.0);
let specularWeight = extension2.z * ${sample(11)}.a;
let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb;
var f0 = ${specularGlossiness ? "packedMaterial.rgb * extension3.rgb" : "mix(dielectricColor, base.rgb, metallic)"};
${
iridescence
? `let iridescenceAmount = clamp(extension4.x * ${sample(8)}.r, 0.0, 1.0);
let iridescenceThickness = mix(extension4.z, extension4.w, ${sample(9)}.g);
let iridescencePhase = iridescenceThickness * 0.025 + (1.0 - nDotV) * 6.0;
let iridescenceTint = 0.12 + 0.88 * (vec3(0.5) + vec3(0.5) * cos(vec3(iridescencePhase, iridescencePhase + 2.0944, iridescencePhase + 4.1888)));
let iridescenceLevel = max(max(f0.r, f0.g), max(f0.b, 0.04));
f0 = mix(f0, iridescenceTint * iridescenceLevel, iridescenceAmount * (1.0 - roughness * 0.5));`
: ""
}
let fresnelPower = pow5(1.0 - vDotH);
let fresnel = f0 + (vec3(1.0) - f0) * fresnelPower;
let alpha = roughness * roughness;
let alpha2 = alpha * alpha;
let denominator = nDotH * nDotH * (alpha2 - 1.0) + 1.0;
let distribution = alpha2 / max(3.14159265 * denominator * denominator, 0.0001);
let geometryV = nDotV / mix(nDotV, 1.0, alpha * 0.5);
let geometryL = nDotL / mix(nDotL, 1.0, alpha * 0.5);
let specular = distribution * geometryV * geometryL * fresnel / max(4.0 * nDotV * nDotL, 0.0001);
let clusterLight = vec3<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 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);
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) {
let colorIntensity = pointLights[i * 2u];
let state = pointLights[i * 2u + 1u];
if (state.y == 0.0 || state.x <= 0.0) { continue; }
let offset = positions[i].xyz - input.world;
let distance = length(offset);
let attenuation = pow(max(1.0 - distance / state.x, 0.0), 2.0);
let incidence = max(dot(normal, offset / max(distance, 0.0001)), 0.0);
pointDiffuse += colorIntensity.rgb * colorIntensity.a * attenuation * incidence * 0.08;
let pointCount = clusters[10];
if (pointCount <= ${compactLightLimit}u) {
for (var light = 0u; light < pointCount; light++) {
pointDiffuse += pointContribution(clusters[${compactPointStart}u + light], normal, input.world);
}
} else {
for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) {
pointDiffuse += pointContribution(light, normal, input.world);
}
}
var spotDiffuse = vec3<f32>(0.0);
for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) {
let colorIntensity = spotLights[i * 2u];
let state = spotLights[i * 2u + 1u];
if (state.w == 0.0 || state.x <= 0.0) { continue; }
let fromLight = input.world - positions[i].xyz;
let distance = length(fromLight);
let lightDirection = normalize(rotate(rotors[i], vec3<f32>(0.0, 0.0, -1.0)));
let cone = smoothstep(cos(state.z), cos(state.y), dot(lightDirection, fromLight / max(distance, 0.0001)));
let attenuation = pow(max(1.0 - distance / state.x, 0.0), 2.0);
let incidence = max(dot(normal, -fromLight / max(distance, 0.0001)), 0.0);
spotDiffuse += colorIntensity.rgb * colorIntensity.a * cone * attenuation * incidence * 0.08;
let spotCount = clusters[${compactSpotCount}];
if (spotCount <= ${compactLightLimit}u) {
for (var light = 0u; light < spotCount; light++) {
spotDiffuse += spotContribution(clusters[${compactSpotStart}u + light], normal, input.world);
}
} else {
for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) {
spotDiffuse += spotContribution(light, normal, input.world);
}
}
let defaultLight = select(1.0, 0.18, clusters[0] != 0u);
let diffuse = base.rgb * (1.0 - metallic) * nDotL * defaultLight;
let emissive = vec3(properties.z, properties.w, extra.x);
let occlusion = mix(1.0, ${sample(4)}.r, extension2.w);
let diffuseColor = base.rgb * (vec3(1.0) - fresnel) * (1.0 - metallic) / 3.14159265;
let diffuse = (diffuseColor + specular) * nDotL * defaultLight;
let reflected = studioEnvironment(reflect(-view, normal));
let environmentFresnel = f0 + (vec3(1.0) - f0) * pow5(1.0 - nDotV);
let environment = (base.rgb * (1.0 - metallic) * studioEnvironment(normal) * 0.5 + reflected * environmentFresnel * (1.0 - roughness * 0.75)) * occlusion;
let emissive = vec3(properties.z, properties.w, extra.x) * ${sample(3, "vec4(1.0)")}.rgb * extension1.w;
let localDiffuse = base.rgb * (1.0 - metallic) * (pointDiffuse + spotDiffuse);
return vec4((base.rgb * ambient + diffuse + localDiffuse + specular * nDotL * defaultLight + emissive) * accent.a, base.a);
${sheen ? "let sheen = extension3.rgb * pow(1.0 - nDotV, mix(2.0, 8.0, extension3.w));" : ""}
${
clearcoat
? `let clearcoatFresnel = 0.04 + 0.96 * fresnelPower;
let clearcoat = extension2.x * ${sample(7)}.r * clearcoatFresnel * pow(nDotH, mix(256.0, 4.0, extension2.y));`
: ""
}
var color = base.rgb * ambient * occlusion + diffuse + environment + localDiffuse${sheen ? " + sheen" : ""}${clearcoat ? " + vec3(clearcoat)" : ""} + emissive;
${unlit ? "color = base.rgb + emissive;" : ""}
${
transmission
? `let transmissionAmount = clamp(extension0.x * ${sample(5)}.r, 0.0, 1.0);
let thickness = max(extension0.z * ${sample(6)}.g, 0.0);
let screenUv = input.position.xy / vec2<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 */ `
struct Accent { color: vec4<f32> }
@group(0) @binding(0) var<uniform> accent: Accent;
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;
const clearShader = /* wgsl */ `
@vertex fn vertex() -> @builtin(position) vec4<f32> {
return vec4(0.0);
}
@fragment fn fragment() -> @location(0) vec4<f32> {
return vec4(accent.color.rgb, 1.0);
return vec4(0.0);
}`;
const fullscreenVertex = /* wgsl */ `
@@ -311,6 +502,28 @@ struct VertexOutput {
return output;
}`;
const copyShader = `${fullscreenVertex}
@group(0) @binding(0) var source: texture_2d<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>) {
const amount = Number(options.amount ?? options.exposure ?? 1);
const safeAmount = Number.isFinite(amount) ? amount : 1;
@@ -330,7 +543,10 @@ function effectFragment(kind: string, options: Record<string, unknown>) {
}`;
}
function presentShader(toneMap: string) {
function presentShader(
toneMap: string,
composite: "studio" | "scene" | undefined,
) {
const tone =
toneMap === "reinhard"
? "color / (color + vec3(1.0))"
@@ -338,12 +554,14 @@ function presentShader(toneMap: string) {
? "clamp(color, vec3(0.0), vec3(1.0))"
: "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))";
return `${fullscreenVertex}
${composite === "studio" ? studioBackground : ""}
@group(0) @binding(0) var source: texture_2d<f32>;
@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> {
let value = textureSample(source, sourceSampler, input.uv);
let color = max(value.rgb, vec3(0.0));
return vec4(${tone}, value.a);
let color = max(${composite === "studio" ? "value.rgb + studioBackground(input.uv) * (1.0 - value.a)" : composite === "scene" ? "value.rgb + textureSample(scene, sourceSampler, input.uv).rgb * (1.0 - value.a)" : "value.rgb"}, vec3(0.0));
return vec4(${tone}, ${composite ? "1.0" : "value.a"});
}`;
}
@@ -402,7 +620,12 @@ export class Scene {
constructor(
canvas: HTMLCanvasElement,
options: { arenaBytes?: number; debug?: boolean; fps?: number; hdr?: boolean } = {},
options: {
arenaBytes?: number;
debug?: boolean;
fps?: number;
hdr?: boolean;
} = {},
) {
this.hdr = options.hdr ?? true;
this.core = new YawnCore(canvas, {
@@ -429,7 +652,22 @@ export class Scene {
const material = await this.core.allocateObject("materials");
this.core
.array("materials")
.write(material, [1, 1, 1, 1, 0, 0.7, 0, 0, 0, 0, 1, 0.5]);
.write(material, [1, 1, 1, 1, 0, 0.7, 0, 0, 0, 1, 0.5, 0]);
this.core
.array("materialExtensions")
.write(
material,
[
0, 1.5, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1.3, 100, 400, 0,
0, 0, 0, 1, 1, 1, 0,
],
);
const textureInfo = this.core.array("materialTextureInfo").row(material);
for (let slot = 0; slot < 13; slot++) {
textureInfo[slot * 8 + 2] = 1;
textureInfo[slot * 8 + 3] = 1;
textureInfo[slot * 8 + 5] = 1;
}
if (fps !== undefined) await this.core.setFps(fps);
await this.#compileRenderGraph();
return this;
@@ -471,7 +709,8 @@ export class Scene {
const value = Reflect.get(target, property, target);
if (typeof value !== "function") return value;
if (property === "constructor") return value;
if (!typedArrayMutators.has(String(property))) return value.bind(target);
if (!typedArrayMutators.has(String(property)))
return value.bind(target);
return (...arguments_: unknown[]) => {
const result = Reflect.apply(value, target, arguments_);
this.markDirty();
@@ -570,9 +809,21 @@ export class Scene {
{
name: "materialTextures",
rows: materialCapacity,
stride: 32,
stride: 64,
format: "u32" as const,
},
{
name: "materialExtensions",
rows: materialCapacity,
stride: 112,
format: "f32" as const,
},
{
name: "materialTextureInfo",
rows: materialCapacity,
stride: 416,
format: "f32" as const,
},
].filter((request) => this.array(request.name).rows < request.rows);
if (growth.length) await this.core.createRowsBatch(growth);
}
@@ -623,7 +874,11 @@ export class Scene {
async allocateMaterial() {
await this.ready;
const id = await this.core.allocateObject("materials");
await this.ensureRows("materialTextures", id + 1, 32, "u32");
await Promise.all([
this.ensureRows("materialTextures", id + 1, 64, "u32"),
this.ensureRows("materialExtensions", id + 1, 112, "f32"),
this.ensureRows("materialTextureInfo", id + 1, 416, "f32"),
]);
return id;
}
@@ -713,6 +968,7 @@ export class Scene {
normals: 3,
tangents: 4,
uvs: 2,
uvs2: 2,
colors: 4,
indices: 1,
};
@@ -850,9 +1106,34 @@ export class Scene {
}
const computeIds = [...this.#computePasses.keys()];
const renderedMeshes = [...this.#meshes.values()].filter(
(mesh) => mesh.vertexCount > 0,
);
const renderCategory = (material: number) => {
const flags = Math.round(this.array("materials").row(material)[11]);
const transmitted = this.array("materialExtensions").row(material)[0] > 0;
return transmitted ? 1 : (flags & 3) === 2 ? 2 : 0;
};
const renderedMeshes = [...this.#meshes.values()]
.filter((mesh) => mesh.vertexCount > 0)
.sort(
(left, right) =>
renderCategory(Number(this.array("meshInfo").row(left.id)[1])) -
renderCategory(Number(this.array("meshInfo").row(right.id)[1])),
);
const hasTransmission = renderedMeshes.some((mesh) => {
const materials = new Set([
Number(this.array("meshInfo").row(mesh.id)[1]),
...mesh.faceMaterials.values(),
]);
return [...materials].some(
(material) => this.array("materialExtensions").row(material)[0] > 0,
);
});
const compositeStudioBackground =
renderedMeshes.length > 0 &&
!hasTransmission &&
this.#effects.size === 0 &&
this.#shaders.size === 0;
const compositeSceneColor =
hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0;
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
addTexture({
id: "hdr",
@@ -868,6 +1149,14 @@ export class Scene {
usage: ["render"],
transient: true,
});
if (compositeSceneColor)
addTexture({
id: "scene-color",
format: hdrFormat,
size: ["canvas", "canvas", 1],
usage: ["render", "sampled"],
transient: true,
});
addSampler({ id: "linear", magFilter: "linear", minFilter: "linear" });
addSampler({
id: "material-linear",
@@ -878,14 +1167,21 @@ export class Scene {
addressModeV: "repeat",
anisotropyClamp: 16,
});
for (const { number: _, source: __, ...texture } of this.#textures.values())
for (const {
number: _,
source: __,
sampler,
...texture
} of this.#textures.values()) {
addTexture(texture);
if (sampler) addSampler({ id: `${texture.id}-sampler`, ...sampler });
}
let previous = computeIds.length ? computeIds : ["cluster-lights"];
if (!renderedMeshes.length) {
renderPipelines.push({
id: "empty-forward",
code: emptyForwardShader,
code: clearShader,
vertex: { entry: "vertex" },
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
});
@@ -894,9 +1190,8 @@ export class Scene {
type: "render",
pipeline: "empty-forward",
after: previous,
bindings: [{ group: 0, binding: 0, resource: "accent" }],
color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }],
draw: { vertices: 3 },
draw: { vertices: 0 },
});
previous = ["forward-empty"];
} else {
@@ -909,8 +1204,33 @@ export class Scene {
["camera-matrices", "cameraMatrices"],
])
addBuffer({ id, array, usage: ["storage"] });
const forwardPipelines = new Set<string>();
let firstRender = true;
if (!compositeStudioBackground) {
renderPipelines.push({
id: "studio-background",
code: studioBackgroundShader,
vertex: { entry: "vertex" },
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
});
passes.push({
id: "studio-background",
type: "render",
pipeline: "studio-background",
after: previous,
color: [
{
resource: compositeSceneColor ? "scene-color" : "hdr",
clear: [0, 0, 0, 1],
},
],
draw: { vertices: 3 },
});
previous = ["studio-background"];
}
const forwardPipelines = new Map<string, string>();
let firstColor = true;
let firstDepth = true;
let sceneColorReady = false;
let forwardTarget = compositeSceneColor ? "scene-color" : "hdr";
for (const mesh of renderedMeshes) {
const vertex = `geometry-${mesh.geometryId}-positions`;
addBuffer({
@@ -921,14 +1241,20 @@ export class Scene {
});
const normal = `geometry-${mesh.geometryId}-normals`;
const uv = `geometry-${mesh.geometryId}-uvs`;
const uv2 = `geometry-${mesh.geometryId}-uvs2`;
const tangent = `geometry-${mesh.geometryId}-tangents`;
const color = `geometry-${mesh.geometryId}-colors`;
const hasNormals = !!this.geometryData(mesh.geometryId, "normals");
const hasUvs = !!this.geometryData(mesh.geometryId, "uvs");
const hasUvs2 = !!this.geometryData(mesh.geometryId, "uvs2");
const hasTangents = !!this.geometryData(mesh.geometryId, "tangents");
const hasColors = !!this.geometryData(mesh.geometryId, "colors");
for (const [present, id, kind] of [
[hasNormals, normal, "normals"],
[hasUvs, uv, "uvs"],
[hasUvs2, uv2, "uvs2"],
[hasTangents, tangent, "tangents"],
[hasColors, color, "colors"],
] as const)
if (present)
addBuffer({
@@ -977,21 +1303,91 @@ export class Scene {
pointers[lane]
? this.#textures.get(Number(pointers[lane]) - 1)
: undefined;
const baseTexture = texture(0);
const materialTexture = texture(1);
const normalTexture = hasTangents ? texture(2) : undefined;
const materialTextures = Array.from({ length: 13 }, (_, lane) =>
lane === 2 && !hasTangents ? undefined : texture(lane),
);
const normalTexture = materialTextures[2];
const detailed = hasNormals && hasUvs;
const mask = detailed
? (baseTexture ? 1 : 0) |
(materialTexture ? 2 : 0) |
(normalTexture ? 4 : 0)
const flags = Math.round(this.array("materials").row(material)[11]);
const extensionInfo = this.array("materialExtensions").row(material);
const transmitted = extensionInfo[0] > 0;
let mask = detailed
? materialTextures.reduce(
(value, current, lane) =>
value | (current === undefined ? 0 : 1 << lane),
0,
)
: 0;
const pipeline = detailed ? `forward-pbr-${mask}` : "forward-basic";
if (!forwardPipelines.has(pipeline)) {
forwardPipelines.add(pipeline);
if (!transmitted) mask &= ~((1 << 5) | (1 << 6));
else if (extensionInfo[2] <= 0) mask &= ~(1 << 6);
if (extensionInfo[8] <= 0) mask &= ~(1 << 7);
if (extensionInfo[16] <= 0) mask &= ~((1 << 8) | (1 << 9));
if (extensionInfo[20] <= 0) mask &= ~(1 << 10);
if (extensionInfo[11] <= 0) mask &= ~(1 << 4);
if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3);
const tangentSlot = normalTexture ? 3 : -1;
const colorSlot = hasColors ? 3 + Number(!!normalTexture) : -1;
const uv2Slot = hasUvs2
? 3 + Number(!!normalTexture) + Number(hasColors)
: -1;
const textureInfo = this.array("materialTextureInfo").row(material);
let clearCompositeLayer = false;
if (transmitted && !sceneColorReady) {
sceneColorReady = true;
if (compositeSceneColor) {
forwardTarget = "hdr";
clearCompositeLayer = true;
} else {
addTexture({
id: "scene-color",
format: hdrFormat,
size: ["canvas", "canvas", 1],
usage: ["render", "sampled"],
transient: true,
});
renderPipelines.push({
id: "copy-scene-color",
code: copyShader,
vertex: { entry: "vertex" },
fragment: {
entry: "fragment",
targets: [{ format: hdrFormat }],
},
});
passes.push({
id: "copy-scene-color",
type: "render",
pipeline: "copy-scene-color",
after: previous,
bindings: [
{ group: 0, binding: 0, resource: "hdr" },
{ group: 0, binding: 1, resource: "linear" },
],
color: [{ resource: "scene-color", clear: [0, 0, 0, 1] }],
draw: { vertices: 3 },
});
previous = ["copy-scene-color"];
}
}
const code = detailed
? pbrShader(
mask,
flags,
hasColors,
hasUvs2,
transmitted,
textureInfo,
extensionInfo,
)
: basicForwardShader;
const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`;
let pipeline = forwardPipelines.get(pipelineKey);
if (!pipeline) {
pipeline = `forward-${detailed ? "pbr" : "basic"}-${forwardPipelines.size}`;
forwardPipelines.set(pipelineKey, pipeline);
renderPipelines.push({
id: pipeline,
code: detailed ? pbrShader(mask) : basicForwardShader,
code,
vertex: {
entry: "vertex",
buffers: detailed
@@ -1040,6 +1436,34 @@ export class Scene {
},
]
: []),
...(hasColors
? [
{
arrayStride: 16,
attributes: [
{
format: "float32x4",
offset: 0,
shaderLocation: 4,
},
],
},
]
: []),
...(hasUvs2
? [
{
arrayStride: 16,
attributes: [
{
format: "float32x2",
offset: 0,
shaderLocation: 5,
},
],
},
]
: []),
]
: [
{
@@ -1056,21 +1480,45 @@ export class Scene {
},
fragment: {
entry: "fragment",
targets: [{ format: hdrFormat }],
targets: [
{
format: hdrFormat,
...((flags & 3) === 2
? {
blend: {
color: {
srcFactor: "src-alpha",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
alpha: {
srcFactor: "one",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
},
}
: {}),
},
],
},
depthStencil: {
format: "depth24plus",
depth_write_enabled: true,
depth_write_enabled: (flags & 3) !== 2 && !transmitted,
depth_compare: "less",
},
primitive: {
topology: "triangle-list",
frontFace: "ccw",
cullMode: (flags & 4) !== 0 ? null : "back",
conservative: false,
},
});
}
const instance =
(mesh.id +
(draw.material === undefined
? 0
: (draw.material + 1) * 65536)) >>>
0;
const instance = (mesh.id + (material + 1) * 65536) >>> 0;
const samplerTexture = materialTextures.find(
(value, lane) => value !== undefined && (mask & (1 << lane)) !== 0,
);
passes.push({
id,
type: "render",
@@ -1099,48 +1547,45 @@ export class Scene {
: []),
...(detailed && mask
? [
{ group: 1, binding: 0, resource: "material-linear" },
...(baseTexture
? [
{
group: 1,
binding: 1,
resource: baseTexture.id,
},
]
: []),
...(materialTexture
? [
{
group: 1,
binding: 2,
resource: materialTexture.id,
},
]
: []),
...(normalTexture
? [
{
group: 1,
binding: 3,
resource: normalTexture.id,
},
]
: []),
{
group: 1,
binding: 0,
resource: samplerTexture?.sampler
? `${samplerTexture.id}-sampler`
: "material-linear",
},
...materialTextures.flatMap((value, lane) =>
value && (mask & (1 << lane)) !== 0
? [
{
group: 1,
binding: lane + 1,
resource: value.id,
},
]
: [],
),
]
: []),
...(detailed && transmitted
? [
{ group: 2, binding: 0, resource: "scene-color" },
{ group: 2, binding: 1, resource: "linear" },
]
: []),
],
color: [
{
resource: "hdr",
...(firstRender
? { clear: [0.015, 0.025, 0.05, 1] }
resource: forwardTarget,
...((firstColor && compositeStudioBackground) ||
clearCompositeLayer
? { clear: [0, 0, 0, 0] }
: { load: "load" }),
},
],
depth: {
resource: "depth",
...(firstRender ? { clear: 1 } : { load: "load" }),
...(firstDepth ? { clear: 1 } : { load: "load" }),
},
vertexBuffers: [
{ slot: 0, resource: vertex },
@@ -1148,7 +1593,13 @@ export class Scene {
? [
{ slot: 1, resource: normal },
{ slot: 2, resource: uv },
...(normalTexture ? [{ slot: 3, resource: tangent }] : []),
...(normalTexture
? [{ slot: tangentSlot, resource: tangent }]
: []),
...(hasColors
? [{ slot: colorSlot, resource: color }]
: []),
...(hasUvs2 ? [{ slot: uv2Slot, resource: uv2 }] : []),
]
: []),
],
@@ -1169,7 +1620,8 @@ export class Scene {
}
: { vertices: draw.count, instances: 1, firstInstance: instance },
});
firstRender = false;
firstColor = false;
firstDepth = false;
previous = [id];
}
}
@@ -1237,7 +1689,14 @@ export class Scene {
);
renderPipelines.push({
id: "present",
code: presentShader(toneMap),
code: presentShader(
toneMap,
compositeStudioBackground
? "studio"
: compositeSceneColor
? "scene"
: undefined,
),
vertex: { entry: "vertex" },
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
});
@@ -1249,6 +1708,9 @@ export class Scene {
bindings: [
{ group: 0, binding: 0, resource: input },
{ group: 0, binding: 1, resource: "linear" },
...(compositeSceneColor
? [{ group: 0, binding: 2, resource: "scene-color" }]
: []),
],
color: [{ resource: "canvas", clear: [0, 0, 0, 1] }],
draw: { vertices: 3 },
+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) {
const row = this.cameraRow();
this.#updateTransform(() => { row[13] = value; });
this.#updateTransform(() => {
row[13] = value;
});
}
get beta() {
return this.cameraRow()[14];
}
get beta() { return this.cameraRow()[14]; }
set beta(value: number) {
const row = this.cameraRow();
this.#updateTransform(() => {
row[14] = Math.min(Math.PI - 0.001, Math.max(0.001, value));
});
}
get radius() { return this.cameraRow()[15]; }
get radius() {
return this.cameraRow()[15];
}
set radius(value: number) {
const row = this.cameraRow();
this.#updateTransform(() => { row[15] = Math.max(0.01, value); });
this.#updateTransform(() => {
row[15] = Math.max(0.01, value);
});
}
get target() { return this.#target; }
get target() {
return this.#target;
}
set target(value: Node | undefined) {
if (value && (value.scene !== this.scene || value.id < 0)) throw new Error("Target must be a ready Node in this Scene");
if (value && (value.scene !== this.scene || value.id < 0))
throw new Error("Target must be a ready Node in this Scene");
this.#target = value;
const row = this.cameraRow();
this.#updateTransform(() => { row[12] = value ? value.id + 1 : 0; });
this.#updateTransform(() => {
row[12] = value ? value.id + 1 : 0;
});
}
attachControls(controls: ArcRotateControls) {
@@ -80,9 +95,12 @@ export class ArcRotateCamera extends Camera {
controls.element.addEventListener("pointerup", this.#up);
controls.element.addEventListener("pointercancel", this.#up);
controls.element.addEventListener("contextmenu", this.#contextMenu);
controls.element.addEventListener("wheel", this.#wheel, { passive: false });
controls.element.addEventListener("wheel", this.#wheel, {
passive: false,
});
}
if (controls.controller) this.#frame = requestAnimationFrame(this.#pollController);
if (controls.controller)
this.#frame = requestAnimationFrame(this.#pollController);
return this;
}
@@ -96,7 +114,7 @@ export class ArcRotateCamera extends Camera {
element.removeEventListener("contextmenu", this.#contextMenu);
element.removeEventListener("wheel", this.#wheel);
}
cancelAnimationFrame(this.#frame);
if (this.#frame) cancelAnimationFrame(this.#frame);
this.#frame = 0;
this.#pointer = undefined;
this.#controls = undefined;
@@ -106,11 +124,7 @@ export class ArcRotateCamera extends Camera {
const row = this.cameraRow();
if (!this.#target) return row.subarray(16, 19);
const position = this.#target.position;
return [
position.x + row[16],
position.y + row[17],
position.z + row[18],
];
return [position.x + row[16], position.y + row[17], position.z + row[18]];
}
#updateTransform(change?: () => void) {
@@ -130,10 +144,16 @@ export class ArcRotateCamera extends Camera {
#down = (event: PointerEvent) => {
event.preventDefault();
this.#pointer = { x: event.clientX, y: event.clientY, button: event.button };
this.#pointer = {
x: event.clientX,
y: event.clientY,
button: event.button,
};
this.#controls?.element.setPointerCapture?.(event.pointerId);
};
#up = () => { this.#pointer = undefined; };
#up = () => {
this.#pointer = undefined;
};
#contextMenu = (event: Event) => event.preventDefault();
#move = (event: PointerEvent) => {
if (!this.#pointer || !this.#controls) return;
@@ -144,9 +164,17 @@ export class ArcRotateCamera extends Camera {
if (this.#pointer.button === 2) {
const row = this.cameraRow();
this.#updateTransform(() => {
const speed = this.#controls?.panSpeed ?? 0.005;
const horizontal = -x * speed;
const vertical = y * speed;
const element = this.#controls!.element;
const verticalExtent =
this.projection === "orthographic"
? this.orthoSize
: 2 * this.radius * Math.tan(this.fov / 2);
const speed = this.#controls?.panSpeed ?? 1;
const horizontal =
(-x * verticalExtent * this.aspect * speed) /
Math.max(1, element.clientWidth);
const vertical =
(y * verticalExtent * speed) / Math.max(1, element.clientHeight);
const sinAlpha = Math.sin(row[13]);
const cosAlpha = Math.cos(row[13]);
const sinBeta = Math.sin(row[14]);
@@ -185,17 +213,12 @@ export class ArcRotateCamera extends Camera {
row[13] += (pad.axes[2] ?? pad.axes[0] ?? 0) * 0.03;
row[14] = Math.min(
Math.PI - 0.001,
Math.max(
0.001,
row[14] + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03,
),
Math.max(0.001, row[14] + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03),
);
row[15] = Math.max(
0.01,
row[15] +
((pad.buttons[6]?.value ?? 0) -
(pad.buttons[7]?.value ?? 0)) *
0.1,
((pad.buttons[6]?.value ?? 0) - (pad.buttons[7]?.value ?? 0)) * 0.1,
);
});
}
+32 -4
View File
@@ -1,5 +1,8 @@
import { Mesh } from "../Mesh";
import type { Scene } from "../Scene";
import { DirectionalLight } from "../lights/DirectionalLight";
import { PointLight } from "../lights/PointLight";
import { SpotLight } from "../lights/SpotLight";
import { PBRMaterial } from "../materials/PBRMaterial";
import { Texture } from "../materials/Texture";
@@ -49,12 +52,20 @@ export async function importGltf(scene: Scene, url: string | URL) {
for (const material of result.materials) {
if (material.baseColorTexture >= 0) srgb.add(material.baseColorTexture);
if (material.emissiveTexture >= 0) srgb.add(material.emissiveTexture);
if (material.flags & 16 && material.metallicRoughnessTexture >= 0)
srgb.add(material.metallicRoughnessTexture);
if (material.specularColorTexture >= 0)
srgb.add(material.specularColorTexture);
}
textures = result.textures.map(
({ image }: { image: ImageBitmap }, index: number) =>
(
{ image, sampler }: { image: ImageBitmap; sampler: any },
index: number,
) =>
new Texture(scene, {
source: image,
format: srgb.has(index) ? "rgba8unorm-srgb" : "rgba8unorm",
sampler,
}),
);
await Promise.all(textures.map((texture: Texture) => texture.ready));
@@ -63,10 +74,19 @@ export async function importGltf(scene: Scene, url: string | URL) {
new PBRMaterial(scene, {
...options,
baseColorTexture: textures[options.baseColorTexture],
metallicRoughnessTexture:
textures[options.metallicRoughnessTexture],
metallicRoughnessTexture: textures[options.metallicRoughnessTexture],
normalTexture: textures[options.normalTexture],
emissiveTexture: textures[options.emissiveTexture],
occlusionTexture: textures[options.occlusionTexture],
transmissionTexture: textures[options.transmissionTexture],
thicknessTexture: textures[options.thicknessTexture],
clearcoatTexture: textures[options.clearcoatTexture],
iridescenceTexture: textures[options.iridescenceTexture],
iridescenceThicknessTexture:
textures[options.iridescenceThicknessTexture],
anisotropyTexture: textures[options.anisotropyTexture],
specularTexture: textures[options.specularTexture],
specularColorTexture: textures[options.specularColorTexture],
}),
);
await Promise.all(materials.map((material: PBRMaterial) => material.ready));
@@ -83,12 +103,20 @@ export async function importGltf(scene: Scene, url: string | URL) {
...(primitive.normals ? { normals: primitive.normals } : {}),
...(primitive.tangents ? { tangents: primitive.tangents } : {}),
...(primitive.uvs ? { uvs: primitive.uvs } : {}),
...(primitive.uvs2 ? { uvs2: primitive.uvs2 } : {}),
...(primitive.colors ? { colors: primitive.colors } : {}),
},
}),
);
await Promise.all(meshes.map((mesh) => mesh.ready));
return meshes;
const lights = (result.lights ?? []).map((options: any) => {
if (options.type === "directional")
return new DirectionalLight(scene, options);
if (options.type === "spot") return new SpotLight(scene, options);
return new PointLight(scene, options);
});
await Promise.all(lights.map((light: any) => light.ready));
return Object.assign(meshes, { camera: result.camera });
});
await Promise.all(
result.textures.map((_: unknown, index: number) =>
+747 -163
View File
@@ -1,7 +1,15 @@
export {};
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> = {
5120: 1,
5121: 1,
@@ -29,79 +37,165 @@ function component(view: DataView, offset: number, type: number) {
throw new Error("GLTF_COMPONENT");
}
function rotate(rotor: number[], value: number[]) {
const [rx, ry, rz, rw] = rotor;
const [x, y, z] = value;
const tx = 2 * (ry * z - rz * y);
const ty = 2 * (rz * x - rx * z);
const tz = 2 * (rx * y - ry * x);
return [
x + rw * tx + ry * tz - rz * ty,
y + rw * ty + rz * tx - rx * tz,
z + rw * tz + rx * ty - ry * tx,
];
}
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
function multiply(a: number[], b: number[]) {
return [
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
];
}
function decompose(matrix: number[]) {
const scale = [
Math.hypot(matrix[0], matrix[1], matrix[2]),
Math.hypot(matrix[4], matrix[5], matrix[6]),
Math.hypot(matrix[8], matrix[9], matrix[10]),
];
const [sx, sy, sz] = scale;
const [m00, m01, m02] = [matrix[0] / sx, matrix[4] / sy, matrix[8] / sz];
const [m10, m11, m12] = [matrix[1] / sx, matrix[5] / sy, matrix[9] / sz];
const [m20, m21, m22] = [matrix[2] / sx, matrix[6] / sy, matrix[10] / sz];
let rotor: number[];
if (m00 + m11 + m22 > 0) {
const s = Math.sqrt(1 + m00 + m11 + m22) * 2;
rotor = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4];
} else if (m00 > m11 && m00 > m22) {
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
rotor = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
} else if (m11 > m22) {
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
rotor = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s];
} else {
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
rotor = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s];
}
return { position: matrix.slice(12, 15), rotor, scale };
function multiply(a: ArrayLike<number>, b: ArrayLike<number>) {
const result = new Array<number>(16);
for (let column = 0; column < 4; column++)
for (let row = 0; row < 4; row++) {
let value = 0;
for (let lane = 0; lane < 4; lane++)
value += a[lane * 4 + row] * b[column * 4 + lane];
result[column * 4 + row] = value;
}
return result;
}
function transform(node: any) {
return node.matrix
? decompose(node.matrix)
: {
position: node.translation ?? [0, 0, 0],
rotor: node.rotation ?? [0, 0, 0, 1],
scale: node.scale ?? [1, 1, 1],
};
if (node.matrix) return Array.from(node.matrix);
const [x, y, z, w] = node.rotation ?? [0, 0, 0, 1];
const [sx, sy, sz] = node.scale ?? [1, 1, 1];
const [tx, ty, tz] = node.translation ?? [0, 0, 0];
const xx = x * x;
const yy = y * y;
const zz = z * z;
const xy = x * y;
const xz = x * z;
const yz = y * z;
const wx = w * x;
const wy = w * y;
const wz = w * z;
return [
(1 - 2 * (yy + zz)) * sx,
2 * (xy + wz) * sx,
2 * (xz - wy) * sx,
0,
2 * (xy - wz) * sy,
(1 - 2 * (xx + zz)) * sy,
2 * (yz + wx) * sy,
0,
2 * (xz + wy) * sz,
2 * (yz - wx) * sz,
(1 - 2 * (xx + yy)) * sz,
0,
tx,
ty,
tz,
1,
];
}
function compose(parent: any, local: any) {
const position = rotate(
parent.rotor,
local.position.map(
(value: number, lane: number) => value * parent.scale[lane],
),
function determinant(matrix: ArrayLike<number>) {
return (
matrix[0] * (matrix[5] * matrix[10] - matrix[6] * matrix[9]) -
matrix[4] * (matrix[1] * matrix[10] - matrix[2] * matrix[9]) +
matrix[8] * (matrix[1] * matrix[6] - matrix[2] * matrix[5])
);
return {
position: position.map((value, lane) => value + parent.position[lane]),
rotor: multiply(parent.rotor, local.rotor),
scale: local.scale.map(
(value: number, lane: number) => value * parent.scale[lane],
),
};
}
function point(matrix: ArrayLike<number>, source: ArrayLike<number>) {
return [
matrix[0] * source[0] +
matrix[4] * source[1] +
matrix[8] * source[2] +
matrix[12],
matrix[1] * source[0] +
matrix[5] * source[1] +
matrix[9] * source[2] +
matrix[13],
matrix[2] * source[0] +
matrix[6] * source[1] +
matrix[10] * source[2] +
matrix[14],
];
}
function vector(matrix: ArrayLike<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) {
@@ -127,7 +221,127 @@ function parse(bytes: Uint8Array) {
return { document, binary };
}
async function load(url: string) {
function computeNormals(positions: Float32Array, indices: Uint32Array) {
const normals = new Float32Array(positions.length);
for (let face = 0; face + 2 < indices.length; face += 3) {
const a = indices[face] * 3;
const b = indices[face + 1] * 3;
const c = indices[face + 2] * 3;
const ab = [
positions[b] - positions[a],
positions[b + 1] - positions[a + 1],
positions[b + 2] - positions[a + 2],
];
const ac = [
positions[c] - positions[a],
positions[c + 1] - positions[a + 1],
positions[c + 2] - positions[a + 2],
];
const cross = [
ab[1] * ac[2] - ab[2] * ac[1],
ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0],
];
for (const vertex of [a, b, c])
for (let lane = 0; lane < 3; lane++)
normals[vertex + lane] += cross[lane];
}
for (let vertex = 0; vertex < normals.length; vertex += 3) {
const length =
Math.hypot(normals[vertex], normals[vertex + 1], normals[vertex + 2]) ||
1;
for (let lane = 0; lane < 3; lane++) normals[vertex + lane] /= length;
}
return normals;
}
function computeTangents(
positions: Float32Array,
normals: Float32Array,
uvs: Float32Array,
indices: Uint32Array,
) {
const first = new Float32Array(positions.length);
const second = new Float32Array(positions.length);
for (let face = 0; face + 2 < indices.length; face += 3) {
const ia = indices[face];
const ib = indices[face + 1];
const ic = indices[face + 2];
const a = ia * 3;
const b = ib * 3;
const c = ic * 3;
const au = ia * 2;
const bu = ib * 2;
const cu = ic * 2;
const x1 = positions[b] - positions[a];
const x2 = positions[c] - positions[a];
const y1 = positions[b + 1] - positions[a + 1];
const y2 = positions[c + 1] - positions[a + 1];
const z1 = positions[b + 2] - positions[a + 2];
const z2 = positions[c + 2] - positions[a + 2];
const s1 = uvs[bu] - uvs[au];
const s2 = uvs[cu] - uvs[au];
const t1 = uvs[bu + 1] - uvs[au + 1];
const t2 = uvs[cu + 1] - uvs[au + 1];
const divisor = s1 * t2 - s2 * t1;
if (Math.abs(divisor) < 1e-10) continue;
const r = 1 / divisor;
const tangent = [
(t2 * x1 - t1 * x2) * r,
(t2 * y1 - t1 * y2) * r,
(t2 * z1 - t1 * z2) * r,
];
const bitangent = [
(s1 * x2 - s2 * x1) * r,
(s1 * y2 - s2 * y1) * r,
(s1 * z2 - s2 * z1) * r,
];
for (const vertex of [a, b, c])
for (let lane = 0; lane < 3; lane++) {
first[vertex + lane] += tangent[lane];
second[vertex + lane] += bitangent[lane];
}
}
const tangents = new Float32Array((positions.length / 3) * 4);
for (let vertex = 0; vertex < positions.length / 3; vertex++) {
const at = vertex * 3;
const output = vertex * 4;
const normal = normals.subarray(at, at + 3);
const source = first.subarray(at, at + 3);
const projection =
normal[0] * source[0] + normal[1] * source[1] + normal[2] * source[2];
let x = source[0] - normal[0] * projection;
let y = source[1] - normal[1] * projection;
let z = source[2] - normal[2] * projection;
const length = Math.hypot(x, y, z) || 1;
x /= length;
y /= length;
z /= length;
tangents[output] = x;
tangents[output + 1] = y;
tangents[output + 2] = z;
tangents[output + 3] =
normal[0] * (y * second[at + 2] - z * second[at + 1]) +
normal[1] * (z * second[at] - x * second[at + 2]) +
normal[2] * (x * second[at + 1] - y * second[at]) <
0
? -1
: 1;
}
return tangents;
}
function color4(values: Float32Array, width: number) {
if (width === 4) return values;
const result = new Float32Array((values.length / width) * 4);
for (let vertex = 0; vertex < values.length / width; vertex++) {
result.set(values.subarray(vertex * width, vertex * width + 3), vertex * 4);
result[vertex * 4 + 3] = 1;
}
return result;
}
export async function loadGltf(url: string) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP_${response.status}`);
const { document, binary } = parse(
@@ -159,152 +373,522 @@ async function load(url: string) {
return result.blob();
}),
);
const samplerDescriptor = (id: number | undefined) => {
const sampler = id === undefined ? {} : (document.samplers?.[id] ?? {});
const addressMode = (mode: number | undefined) =>
mode === 33071
? "clamp-to-edge"
: mode === 33648
? "mirror-repeat"
: "repeat";
return {
magFilter: sampler.magFilter === 9728 ? "nearest" : "linear",
minFilter:
sampler.minFilter === 9728 ||
sampler.minFilter === 9984 ||
sampler.minFilter === 9986
? "nearest"
: "linear",
mipmapFilter:
sampler.minFilter === 9984 || sampler.minFilter === 9985
? "nearest"
: "linear",
addressModeU: addressMode(sampler.wrapS),
addressModeV: addressMode(sampler.wrapT),
};
};
const textures = await Promise.all(
(document.textures ?? []).map(async (texture: any) => ({
image: await createImageBitmap(imageBlobs[texture.source]),
sampler: samplerDescriptor(texture.sampler),
})),
);
const normalized = (value: number, type: number) => {
if (type === 5120) return Math.max(value / 127, -1);
if (type === 5121) return value / 255;
if (type === 5122) return Math.max(value / 32_767, -1);
if (type === 5123) return value / 65_535;
return value;
};
const accessor = (id: number, integer = false) => {
const source = document.accessors[id];
const width = widths[source.type];
const size = sizes[source.componentType];
const bufferView = document.bufferViews[source.bufferView];
const bytes = buffers[bufferView.buffer];
const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0);
const stride = bufferView.byteStride ?? width * size;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const values = integer
? new Uint32Array(source.count * width)
: new Float32Array(source.count * width);
if (
!source.normalized &&
stride === width * size &&
(bytes.byteOffset + start) % size === 0
) {
const packed = new arrays[source.componentType](
if (source.bufferView !== undefined) {
const bufferView = document.bufferViews[source.bufferView];
const bytes = buffers[bufferView.buffer];
const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0);
const stride = bufferView.byteStride ?? width * size;
const view = new DataView(
bytes.buffer,
bytes.byteOffset + start,
source.count * width,
bytes.byteOffset,
bytes.byteLength,
);
values.set(packed);
return values;
}
for (let item = 0; item < source.count; item++)
for (let lane = 0; lane < width; lane++) {
let value = component(
view,
start + item * stride + lane * size,
source.componentType,
if (
!source.sparse &&
!source.normalized &&
stride === width * size &&
(bytes.byteOffset + start) % size === 0
) {
values.set(
new arrays[source.componentType](
bytes.buffer,
bytes.byteOffset + start,
source.count * width,
),
);
if (!integer && source.normalized) {
const maximum =
source.componentType === 5121
? 255
: source.componentType === 5123
? 65535
: 1;
value /= maximum;
} else
for (let item = 0; item < source.count; item++)
for (let lane = 0; lane < width; lane++) {
let value = component(
view,
start + item * stride + lane * size,
source.componentType,
);
if (!integer && source.normalized)
value = normalized(value, source.componentType);
values[item * width + lane] = value;
}
}
if (source.sparse) {
const sparse = source.sparse;
const indexView = document.bufferViews[sparse.indices.bufferView];
const indexBytes = buffers[indexView.buffer];
const indexStart =
(indexView.byteOffset ?? 0) + (sparse.indices.byteOffset ?? 0);
const indexData = new DataView(
indexBytes.buffer,
indexBytes.byteOffset,
indexBytes.byteLength,
);
const indexSize = sizes[sparse.indices.componentType];
const valueView = document.bufferViews[sparse.values.bufferView];
const valueBytes = buffers[valueView.buffer];
const valueStart =
(valueView.byteOffset ?? 0) + (sparse.values.byteOffset ?? 0);
const valueData = new DataView(
valueBytes.buffer,
valueBytes.byteOffset,
valueBytes.byteLength,
);
for (let item = 0; item < sparse.count; item++) {
const target = component(
indexData,
indexStart + item * indexSize,
sparse.indices.componentType,
);
for (let lane = 0; lane < width; lane++) {
let value = component(
valueData,
valueStart + (item * width + lane) * size,
source.componentType,
);
if (!integer && source.normalized)
value = normalized(value, source.componentType);
values[target * width + lane] = value;
}
values[item * width + lane] = value;
}
}
return values;
};
const materials = (document.materials ?? []).map((material: any) => {
const pbr = material.pbrMetallicRoughness ?? {};
const textureInfo = (info: any) => {
if (!info) return undefined;
const extension = info.extensions?.KHR_texture_transform ?? {};
return {
baseColor: pbr.baseColorFactor ?? [1, 1, 1, 1],
metallic: pbr.metallicFactor ?? 0,
roughness: pbr.roughnessFactor ?? 0.7,
texture: info.index,
texCoord: extension.texCoord ?? info.texCoord ?? 0,
offset: extension.offset ?? [0, 0],
scale: extension.scale ?? [1, 1],
rotation: extension.rotation ?? 0,
};
};
const materials = (document.materials ?? []).map((material: any) => {
const extensions = material.extensions ?? {};
const specGloss = extensions.KHR_materials_pbrSpecularGlossiness;
const pbr = material.pbrMetallicRoughness ?? {};
const transmission = extensions.KHR_materials_transmission ?? {};
const volume = extensions.KHR_materials_volume ?? {};
const clearcoat = extensions.KHR_materials_clearcoat ?? {};
const sheen = extensions.KHR_materials_sheen ?? {};
const specular = extensions.KHR_materials_specular ?? {};
const iridescence = extensions.KHR_materials_iridescence ?? {};
const anisotropy = extensions.KHR_materials_anisotropy ?? {};
const alphaMode =
material.alphaMode === "MASK"
? 1
: material.alphaMode === "BLEND"
? 2
: 0;
const flags =
alphaMode |
(material.doubleSided ? 4 : 0) |
(extensions.KHR_materials_unlit ? 8 : 0) |
(specGloss ? 16 : 0);
const infos = [
textureInfo(specGloss?.diffuseTexture ?? pbr.baseColorTexture),
textureInfo(
specGloss?.specularGlossinessTexture ?? pbr.metallicRoughnessTexture,
),
textureInfo(material.normalTexture),
textureInfo(material.emissiveTexture),
textureInfo(material.occlusionTexture),
textureInfo(transmission.transmissionTexture),
textureInfo(volume.thicknessTexture),
textureInfo(clearcoat.clearcoatTexture),
textureInfo(iridescence.iridescenceTexture),
textureInfo(iridescence.iridescenceThicknessTexture),
textureInfo(anisotropy.anisotropyTexture),
textureInfo(specular.specularTexture),
textureInfo(specular.specularColorTexture),
];
return {
baseColor: specGloss?.diffuseFactor ??
pbr.baseColorFactor ?? [1, 1, 1, 1],
metallic: specGloss ? 0 : (pbr.metallicFactor ?? 1),
roughness: specGloss
? 1 - (specGloss.glossinessFactor ?? 1)
: (pbr.roughnessFactor ?? 1),
emissive: material.emissiveFactor ?? [0, 0, 0],
normalScale: material.normalTexture?.scale ?? 1,
alphaCutoff: material.alphaCutoff ?? 0.5,
baseColorTexture: pbr.baseColorTexture?.index ?? -1,
metallicRoughnessTexture: pbr.metallicRoughnessTexture?.index ?? -1,
normalTexture: material.normalTexture?.index ?? -1,
emissiveTexture: material.emissiveTexture?.index ?? -1,
flags,
occlusionStrength: material.occlusionTexture?.strength ?? 1,
transmission: transmission.transmissionFactor ?? 0,
ior: extensions.KHR_materials_ior?.ior ?? 1.5,
thickness: volume.thicknessFactor ?? 0,
attenuationDistance: volume.attenuationDistance ?? 0,
attenuationColor: volume.attenuationColor ?? [1, 1, 1],
emissiveStrength:
extensions.KHR_materials_emissive_strength?.emissiveStrength ?? 1,
clearcoat: clearcoat.clearcoatFactor ?? 0,
clearcoatRoughness: clearcoat.clearcoatRoughnessFactor ?? 0,
specular: specular.specularFactor ?? 1,
sheenColor: sheen.sheenColorFactor ?? [0, 0, 0],
sheenRoughness: sheen.sheenRoughnessFactor ?? 0,
specularColor: specGloss?.specularFactor ?? [1, 1, 1],
iridescence: iridescence.iridescenceFactor ?? 0,
iridescenceIor: iridescence.iridescenceIor ?? 1.3,
iridescenceThicknessMinimum:
iridescence.iridescenceThicknessMinimum ?? 100,
iridescenceThicknessMaximum:
iridescence.iridescenceThicknessMaximum ?? 400,
anisotropy: anisotropy.anisotropyStrength ?? 0,
anisotropyRotation: anisotropy.anisotropyRotation ?? 0,
specularExtensionColor: specular.specularColorFactor ?? [1, 1, 1],
textureInfos: infos,
baseColorTexture: infos[0]?.texture ?? -1,
metallicRoughnessTexture: infos[1]?.texture ?? -1,
normalTexture: infos[2]?.texture ?? -1,
emissiveTexture: infos[3]?.texture ?? -1,
occlusionTexture: infos[4]?.texture ?? -1,
transmissionTexture: infos[5]?.texture ?? -1,
thicknessTexture: infos[6]?.texture ?? -1,
clearcoatTexture: infos[7]?.texture ?? -1,
iridescenceTexture: infos[8]?.texture ?? -1,
iridescenceThicknessTexture: infos[9]?.texture ?? -1,
anisotropyTexture: infos[10]?.texture ?? -1,
specularTexture: infos[11]?.texture ?? -1,
specularColorTexture: infos[12]?.texture ?? -1,
};
});
const nodes = document.nodes ?? [];
const parents = new Map<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 emitMesh = (meshId: number, transform: any) => {
const emitMesh = (meshId: number, nodeId: number, matrix: number[]) => {
const mesh = document.meshes?.[meshId];
const node = nodes[nodeId] ?? {};
const skin =
node.skin === undefined ? undefined : document.skins?.[node.skin];
const inverseBindMatrices =
skin?.inverseBindMatrices === undefined
? undefined
: (accessor(skin.inverseBindMatrices) as Float32Array);
const jointMatrices = skin?.joints.map((joint: number, index: number) =>
multiply(
world(joint),
inverseBindMatrices?.subarray(index * 16, index * 16 + 16) ?? identity,
),
);
for (const primitive of mesh?.primitives ?? []) {
if (
(primitive.mode ?? 4) !== 4 ||
primitive.attributes.POSITION === undefined
)
continue;
const positions = accessor(primitive.attributes.POSITION);
const positions = accessor(primitive.attributes.POSITION) as Float32Array;
let normals =
primitive.attributes.NORMAL === undefined
? undefined
: (accessor(primitive.attributes.NORMAL) as Float32Array);
let tangents =
primitive.attributes.TANGENT === undefined
? undefined
: (accessor(primitive.attributes.TANGENT) as Float32Array);
const uvs =
primitive.attributes.TEXCOORD_0 === undefined
? new Float32Array((positions.length / 3) * 2)
: (accessor(primitive.attributes.TEXCOORD_0) as Float32Array);
const uvs2 =
primitive.attributes.TEXCOORD_1 === undefined
? undefined
: (accessor(primitive.attributes.TEXCOORD_1) as Float32Array);
const indices =
primitive.indices === undefined
? Uint32Array.from(
{ length: positions.length / 3 },
(_, index) => index,
)
: accessor(primitive.indices, true);
: (accessor(primitive.indices, true) as Uint32Array);
const weights = node.weights ?? mesh.weights ?? [];
for (
let target = 0;
target < (primitive.targets?.length ?? 0);
target++
) {
const weight = weights[target] ?? 0;
if (!weight) continue;
for (const [semantic, values] of [
["POSITION", positions],
["NORMAL", normals],
["TANGENT", tangents],
] as const) {
const targetAccessor = primitive.targets[target][semantic];
if (targetAccessor === undefined || !values) continue;
const delta = accessor(targetAccessor) as Float32Array;
if (semantic === "TANGENT") {
for (let vertex = 0; vertex < delta.length / 3; vertex++)
for (let lane = 0; lane < 3; lane++)
values[vertex * 4 + lane] += delta[vertex * 3 + lane] * weight;
} else {
for (let index = 0; index < delta.length; index++)
values[index] += delta[index] * weight;
}
}
}
const jointAccessor = primitive.attributes.JOINTS_0;
const weightAccessor = primitive.attributes.WEIGHTS_0;
if (
jointMatrices &&
jointAccessor !== undefined &&
weightAccessor !== undefined
) {
const joints = accessor(jointAccessor, true) as Uint32Array;
const weights = accessor(weightAccessor) as Float32Array;
for (let vertex = 0; vertex < positions.length / 3; vertex++) {
const position = [0, 0, 0];
const normal = [0, 0, 0];
const tangent = [0, 0, 0];
let total = 0;
for (let influence = 0; influence < 4; influence++) {
const weight = weights[vertex * 4 + influence];
const joint = jointMatrices[joints[vertex * 4 + influence]];
if (!weight || !joint) continue;
const skinnedPosition = point(
joint,
positions.subarray(vertex * 3, vertex * 3 + 3),
);
const skinnedNormal = normals
? direction(joint, normals.subarray(vertex * 3, vertex * 3 + 3))
: undefined;
const skinnedTangent = tangents
? vector(joint, tangents.subarray(vertex * 4, vertex * 4 + 3))
: undefined;
for (let lane = 0; lane < 3; lane++) {
position[lane] += skinnedPosition[lane] * weight;
if (skinnedNormal) normal[lane] += skinnedNormal[lane] * weight;
if (skinnedTangent)
tangent[lane] += skinnedTangent[lane] * weight;
}
total += weight;
}
if (!total) continue;
positions.set(
position.map((value) => value / total),
vertex * 3,
);
if (normals) {
const length = Math.hypot(...normal) || 1;
normals.set(
normal.map((value) => value / length),
vertex * 3,
);
}
if (tangents) {
const length = Math.hypot(...tangent) || 1;
tangents.set(
tangent.map((value) => value / length),
vertex * 4,
);
}
}
matrix = identity;
}
const mirrored = determinant(matrix) < 0;
for (let vertex = 0; vertex < positions.length; vertex += 3)
positions.set(
point(matrix, positions.subarray(vertex, vertex + 3)),
vertex,
);
if (normals)
for (let vertex = 0; vertex < normals.length; vertex += 3)
normals.set(
direction(matrix, normals.subarray(vertex, vertex + 3)),
vertex,
);
if (tangents)
for (let vertex = 0; vertex < tangents.length; vertex += 4) {
tangents.set(
vector(matrix, tangents.subarray(vertex, vertex + 3)),
vertex,
);
if (mirrored) tangents[vertex + 3] *= -1;
}
if (mirrored)
for (let face = 0; face + 2 < indices.length; face += 3)
[indices[face + 1], indices[face + 2]] = [
indices[face + 2],
indices[face + 1],
];
normals ??= computeNormals(positions, indices);
const material = materials[primitive.material ?? -1];
if (!tangents && material?.normalTexture >= 0)
tangents = computeTangents(positions, normals, uvs, indices);
const colorAccessor = primitive.attributes.COLOR_0;
primitives.push({
positions,
normals,
tangents,
uvs,
uvs2,
indices,
...(primitive.attributes.NORMAL === undefined
...(colorAccessor === undefined
? {}
: { normals: accessor(primitive.attributes.NORMAL) }),
...(primitive.attributes.TANGENT === undefined
? {}
: { tangents: accessor(primitive.attributes.TANGENT) }),
...(primitive.attributes.TEXCOORD_0 === undefined
? {}
: { uvs: accessor(primitive.attributes.TEXCOORD_0) }),
...(primitive.attributes.COLOR_0 === undefined
? {}
: { colors: accessor(primitive.attributes.COLOR_0) }),
: {
colors: color4(
accessor(colorAccessor) as Float32Array,
widths[document.accessors[colorAccessor].type],
),
}),
material: primitive.material ?? -1,
...transform,
position: [0, 0, 0],
rotor: [0, 0, 0, 1],
scale: [1, 1, 1],
});
}
};
const nodes = document.nodes ?? [];
const scene = document.scenes?.[document.scene ?? 0];
const children = new Set(nodes.flatMap((node: any) => node.children ?? []));
const roots =
scene?.nodes ??
nodes
.map((_: any, id: number) => id)
.filter((id: number) => !children.has(id));
const visit = (
id: number,
parent = {
position: [0, 0, 0],
rotor: [0, 0, 0, 1],
scale: [1, 1, 1],
},
) => {
const node = nodes[id] ?? {};
const world = compose(parent, transform(node));
if (node.mesh !== undefined) emitMesh(node.mesh, world);
for (const child of node.children ?? []) visit(child, world);
const emitNode = (id: number) => {
const node = nodes[id];
if (node.mesh === undefined) return;
const instances = node.extensions?.EXT_mesh_gpu_instancing?.attributes;
if (!instances) return emitMesh(node.mesh, id, world(id));
const translations =
instances.TRANSLATION === undefined
? undefined
: (accessor(instances.TRANSLATION) as Float32Array);
const rotations =
instances.ROTATION === undefined
? undefined
: (accessor(instances.ROTATION) as Float32Array);
const scales =
instances.SCALE === undefined
? undefined
: (accessor(instances.SCALE) as Float32Array);
const count = translations
? translations.length / 3
: rotations
? rotations.length / 4
: scales
? scales.length / 3
: 0;
for (let instance = 0; instance < count; instance++)
emitMesh(
node.mesh,
id,
multiply(
world(id),
transform({
translation: translations?.subarray(instance * 3, instance * 3 + 3),
rotation: rotations?.subarray(instance * 4, instance * 4 + 4),
scale: scales?.subarray(instance * 3, instance * 3 + 3),
}),
),
);
};
for (const root of roots) visit(root);
for (let id = 0; id < nodes.length; id++) emitNode(id);
if (!nodes.length)
for (let id = 0; id < (document.meshes ?? []).length; id++)
emitMesh(id, {});
return { materials, primitives, textures };
emitMesh(id, -1, identity);
const lightDefinitions =
document.extensions?.KHR_lights_punctual?.lights ?? [];
const lights = nodes.flatMap((node: any, id: number) => {
const lightId = node.extensions?.KHR_lights_punctual?.light;
const light = lightId === undefined ? undefined : lightDefinitions[lightId];
if (!light) return [];
const matrix = world(id);
return [
{
type: light.type,
color: light.color ?? [1, 1, 1],
intensity: light.intensity ?? 1,
range: light.range ?? 1_000_000,
innerAngle: light.spot?.innerConeAngle ?? 0,
outerAngle: light.spot?.outerConeAngle ?? Math.PI / 4,
position: [matrix[12], matrix[13], matrix[14]],
rotor: rotor(matrix),
},
];
});
const cameraNode = nodes.findIndex((node: any) => node.camera !== undefined);
const cameraDefinition =
cameraNode < 0 ? undefined : document.cameras?.[nodes[cameraNode].camera];
const camera = cameraDefinition
? {
direction: vector(world(cameraNode), [0, 0, 1]),
fov: cameraDefinition.perspective?.yfov,
}
: undefined;
return { camera, lights, materials, primitives, textures };
}
addEventListener("message", async ({ data }) => {
try {
const result = await load(data.url);
const transfers = result.primitives.flatMap((primitive: any) =>
["positions", "indices", "normals", "tangents", "uvs", "colors"]
.map((name) => primitive[name]?.buffer)
.filter(Boolean),
);
transfers.push(...result.textures.map((texture: any) => texture.image));
(postMessage as any)({ request: data.request, result }, transfers);
} catch (error) {
postMessage({
request: data.request,
error: error instanceof Error ? error.message : "GLTF_IMPORT",
});
}
});
if (typeof addEventListener === "function")
addEventListener("message", async ({ data }) => {
try {
const result = await loadGltf(data.url);
const transfers = result.primitives.flatMap((primitive: any) =>
["positions", "indices", "normals", "tangents", "uvs", "uvs2", "colors"]
.map((name) => primitive[name]?.buffer)
.filter(Boolean),
);
transfers.push(...result.textures.map((texture: any) => texture.image));
(postMessage as any)({ request: data.request, result }, transfers);
} catch (error) {
postMessage({
request: data.request,
error: error instanceof Error ? error.message : "GLTF_IMPORT",
});
}
});
+174 -32
View File
@@ -1,6 +1,13 @@
import type { Scene } from "../Scene";
import type { Texture } from "./Texture";
export type PBRTextureInfo = {
texCoord?: number;
offset?: ArrayLike<number>;
scale?: ArrayLike<number>;
rotation?: number;
};
export type PBRMaterialOptions = {
baseColor?: ArrayLike<number>;
metallic?: number;
@@ -12,9 +19,40 @@ export type PBRMaterialOptions = {
metallicRoughnessTexture?: Texture;
normalTexture?: Texture;
emissiveTexture?: Texture;
occlusionTexture?: Texture;
transmissionTexture?: Texture;
thicknessTexture?: Texture;
clearcoatTexture?: Texture;
iridescenceTexture?: Texture;
iridescenceThicknessTexture?: Texture;
anisotropyTexture?: Texture;
specularTexture?: Texture;
specularColorTexture?: Texture;
textureInfos?: ArrayLike<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 {
readonly scene: Scene;
id = -1;
@@ -28,48 +66,152 @@ export class PBRMaterial {
options.metallicRoughnessTexture,
options.normalTexture,
options.emissiveTexture,
options.occlusionTexture,
options.transmissionTexture,
options.thicknessTexture,
options.clearcoatTexture,
options.iridescenceTexture,
options.iridescenceThicknessTexture,
options.anisotropyTexture,
options.specularTexture,
options.specularColorTexture,
].filter((texture): texture is Texture => texture !== undefined);
this.ready = Promise.all(textures.map((texture) => texture.ready)).then(async () => {
const id = await scene.allocateMaterial();
this.id = id;
const color = Array.from(options.baseColor ?? [1, 1, 1, 1]);
const emissive = Array.from(options.emissive ?? [0, 0, 0]);
if (color.length !== 4 || emissive.length !== 3) throw new TypeError("PBR material vectors");
scene.array("materials").write(id, [
...color,
options.metallic ?? 0,
options.roughness ?? 0.7,
...emissive,
options.normalScale ?? 1,
options.alphaCutoff ?? 0.5,
0,
]);
scene.array("materialTextures").write(id, [
(options.baseColorTexture?.id ?? -1) + 1,
(options.metallicRoughnessTexture?.id ?? -1) + 1,
(options.normalTexture?.id ?? -1) + 1,
(options.emissiveTexture?.id ?? -1) + 1,
0, 0, 0, 0,
]);
return this;
});
this.ready = Promise.all(textures.map((texture) => texture.ready)).then(
async () => {
const id = await scene.allocateMaterial();
this.id = id;
const color = Array.from(options.baseColor ?? [1, 1, 1, 1]);
const emissive = Array.from(options.emissive ?? [0, 0, 0]);
const attenuation = Array.from(options.attenuationColor ?? [1, 1, 1]);
const secondary = Array.from(
options.flags && options.flags & 16
? (options.specularColor ?? [1, 1, 1])
: (options.sheenColor ?? [0, 0, 0]),
);
const specularExtensionColor = Array.from(
options.specularExtensionColor ?? [1, 1, 1],
);
if (
color.length !== 4 ||
emissive.length !== 3 ||
attenuation.length !== 3 ||
secondary.length !== 3 ||
specularExtensionColor.length !== 3
)
throw new TypeError("PBR material vectors");
scene
.array("materials")
.write(id, [
...color,
options.metallic ?? 0,
options.roughness ?? 0.7,
...emissive,
options.normalScale ?? 1,
options.alphaCutoff ?? 0.5,
options.flags ?? 0,
]);
scene
.array("materialTextures")
.write(id, [
(options.baseColorTexture?.id ?? -1) + 1,
(options.metallicRoughnessTexture?.id ?? -1) + 1,
(options.normalTexture?.id ?? -1) + 1,
(options.emissiveTexture?.id ?? -1) + 1,
(options.occlusionTexture?.id ?? -1) + 1,
(options.transmissionTexture?.id ?? -1) + 1,
(options.thicknessTexture?.id ?? -1) + 1,
(options.clearcoatTexture?.id ?? -1) + 1,
(options.iridescenceTexture?.id ?? -1) + 1,
(options.iridescenceThicknessTexture?.id ?? -1) + 1,
(options.anisotropyTexture?.id ?? -1) + 1,
(options.specularTexture?.id ?? -1) + 1,
(options.specularColorTexture?.id ?? -1) + 1,
0,
0,
0,
]);
scene
.array("materialExtensions")
.write(id, [
options.transmission ?? 0,
options.ior ?? 1.5,
options.thickness ?? 0,
options.attenuationDistance ?? 0,
...attenuation,
options.emissiveStrength ?? 1,
options.clearcoat ?? 0,
options.clearcoatRoughness ?? 0,
options.specular ?? 1,
options.occlusionStrength ?? 1,
...secondary,
options.sheenRoughness ?? 0,
options.iridescence ?? 0,
options.iridescenceIor ?? 1.3,
options.iridescenceThicknessMinimum ?? 100,
options.iridescenceThicknessMaximum ?? 400,
options.anisotropy ?? 0,
options.anisotropyRotation ?? 0,
0,
0,
...specularExtensionColor,
0,
]);
const transforms = new Float32Array(104);
for (let slot = 0; slot < 13; slot++) {
const info = options.textureInfos?.[slot];
const offset = Array.from(info?.offset ?? [0, 0]);
const scale = Array.from(info?.scale ?? [1, 1]);
if (offset.length !== 2 || scale.length !== 2)
throw new TypeError("PBR texture transform");
const rotation = info?.rotation ?? 0;
transforms.set(
[
offset[0],
offset[1],
scale[0],
scale[1],
Math.sin(rotation),
Math.cos(rotation),
info?.texCoord ?? 0,
0,
],
slot * 8,
);
}
scene.array("materialTextureInfo").write(id, transforms);
return this;
},
);
}
#values() {
if (this.id < 0) throw new Error("Await material.ready before reading or writing it");
if (this.id < 0)
throw new Error("Await material.ready before reading or writing it");
return this.scene.array("materials").row(this.id);
}
get baseColor() { return this.#values().subarray(0, 4); }
get baseColor() {
return this.#values().subarray(0, 4);
}
set baseColor(value: ArrayLike<number>) {
if (value.length !== 4) throw new RangeError("baseColor");
this.#values().set(value, 0);
}
get metallic() { return this.#values()[4]; }
set metallic(value: number) { this.#values()[4] = value; }
get roughness() { return this.#values()[5]; }
set roughness(value: number) { this.#values()[5] = value; }
get emissive() { return this.#values().subarray(6, 9); }
get metallic() {
return this.#values()[4];
}
set metallic(value: number) {
this.#values()[4] = value;
}
get roughness() {
return this.#values()[5];
}
set roughness(value: number) {
this.#values()[5] = value;
}
get emissive() {
return this.#values().subarray(6, 9);
}
set emissive(value: ArrayLike<number>) {
if (value.length !== 3) throw new RangeError("emissive");
this.#values().set(value, 6);
+3
View File
@@ -1,4 +1,5 @@
import type { Scene } from "../Scene";
import type { GraphSampler } from "../ComputePass";
export type TextureOptions = {
source?: string | ImageBitmap;
@@ -7,6 +8,7 @@ export type TextureOptions = {
usage?: string[];
mipmaps?: boolean;
transient?: boolean;
sampler?: Omit<GraphSampler, "id">;
};
let nextTextureName = 1;
@@ -52,6 +54,7 @@ export class Texture {
]),
],
transient: options.transient ?? false,
sampler: options.sampler,
});
this.id = registration.number;
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 },
);
}