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>
@@ -18,7 +18,12 @@ if ! command -v git-lfs >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y git-lfs
|
||||
fi
|
||||
if ! command -v Xvfb >/dev/null || ! command -v magick >/dev/null || ! command -v xauth >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y imagemagick libvulkan1 mesa-vulkan-drivers xauth xvfb
|
||||
fi
|
||||
git lfs install --local
|
||||
git lfs pull --include="docs/public/models/sponza.glb"
|
||||
npm ci
|
||||
npx agent-browser install
|
||||
amp orb services ensure
|
||||
|
||||
@@ -32,6 +32,7 @@ docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
server/data/
|
||||
yawn-data/
|
||||
addons/handles/test/screenshots/output/
|
||||
|
||||
# Amp runtime artifacts
|
||||
.amp/in/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,10 +35,17 @@ 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.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,
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: 0,
|
||||
resource: samplerTexture?.sampler
|
||||
? `${samplerTexture.id}-sampler`
|
||||
: "material-linear",
|
||||
},
|
||||
...materialTextures.flatMap((value, lane) =>
|
||||
value && (mask & (1 << lane)) !== 0
|
||||
? [
|
||||
{
|
||||
group: 1,
|
||||
binding: 1,
|
||||
resource: baseTexture.id,
|
||||
binding: lane + 1,
|
||||
resource: value.id,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(materialTexture
|
||||
...(detailed && transmitted
|
||||
? [
|
||||
{
|
||||
group: 1,
|
||||
binding: 2,
|
||||
resource: materialTexture.id,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(normalTexture
|
||||
? [
|
||||
{
|
||||
group: 1,
|
||||
binding: 3,
|
||||
resource: normalTexture.id,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ 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 },
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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];
|
||||
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 { position: matrix.slice(12, 15), rotor, scale };
|
||||
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,37 +373,75 @@ 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 values = integer
|
||||
? new Uint32Array(source.count * width)
|
||||
: new Float32Array(source.count * width);
|
||||
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, bytes.byteLength);
|
||||
const values = integer
|
||||
? new Uint32Array(source.count * width)
|
||||
: new Float32Array(source.count * width);
|
||||
const view = new DataView(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength,
|
||||
);
|
||||
if (
|
||||
!source.sparse &&
|
||||
!source.normalized &&
|
||||
stride === width * size &&
|
||||
(bytes.byteOffset + start) % size === 0
|
||||
) {
|
||||
const packed = new arrays[source.componentType](
|
||||
values.set(
|
||||
new arrays[source.componentType](
|
||||
bytes.buffer,
|
||||
bytes.byteOffset + start,
|
||||
source.count * width,
|
||||
),
|
||||
);
|
||||
values.set(packed);
|
||||
return values;
|
||||
}
|
||||
} else
|
||||
for (let item = 0; item < source.count; item++)
|
||||
for (let lane = 0; lane < width; lane++) {
|
||||
let value = component(
|
||||
@@ -197,105 +449,437 @@ async function load(url: string) {
|
||||
start + item * stride + lane * size,
|
||||
source.componentType,
|
||||
);
|
||||
if (!integer && source.normalized) {
|
||||
const maximum =
|
||||
source.componentType === 5121
|
||||
? 255
|
||||
: source.componentType === 5123
|
||||
? 65535
|
||||
: 1;
|
||||
value /= maximum;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 }) => {
|
||||
if (typeof addEventListener === "function")
|
||||
addEventListener("message", async ({ data }) => {
|
||||
try {
|
||||
const result = await load(data.url);
|
||||
const result = await loadGltf(data.url);
|
||||
const transfers = result.primitives.flatMap((primitive: any) =>
|
||||
["positions", "indices", "normals", "tangents", "uvs", "colors"]
|
||||
["positions", "indices", "normals", "tangents", "uvs", "uvs2", "colors"]
|
||||
.map((name) => primitive[name]?.buffer)
|
||||
.filter(Boolean),
|
||||
);
|
||||
@@ -307,4 +891,4 @@ addEventListener("message", async ({ data }) => {
|
||||
error: error instanceof Error ? error.message : "GLTF_IMPORT",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
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, [
|
||||
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,
|
||||
0,
|
||||
options.flags ?? 0,
|
||||
]);
|
||||
scene.array("materialTextures").write(id, [
|
||||
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,
|
||||
(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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"devDependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"agent-browser": "0.33.2",
|
||||
"codemirror": "^6.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"monaco-editor": "^0.52.2",
|
||||
@@ -1462,6 +1463,21 @@
|
||||
"resolved": "addons/handles",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/agent-browser": {
|
||||
"version": "0.33.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.33.2.tgz",
|
||||
"integrity": "sha512-e+TZ0G04uw2rs+lVB8gn0IWTT7ErfiAl3jQ4zNNwyqDhgXWJKhqxYKkyibjuBGXLzx/APlzU3IWAsOVdRwh0DA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"agent-browser": "bin/agent-browser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
"pnpm": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/algoliasearch": {
|
||||
"version": "5.57.0",
|
||||
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.57.0.tgz",
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
"start": "wasm-pack build core --target web --out-dir pkg --release && vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}",
|
||||
"server": "go run ./server",
|
||||
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
|
||||
"build:coredocs": "vitepress build coredocs"
|
||||
"build:coredocs": "vitepress build coredocs",
|
||||
"test": "npm run test:handles",
|
||||
"test:handles": "npm test --workspace @yawn/handles",
|
||||
"test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"agent-browser": "0.33.2",
|
||||
"codemirror": "^6.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"monaco-editor": "^0.52.2",
|
||||
|
||||
@@ -35,7 +35,7 @@ func newApplication(store *playgroundStore, packages map[string]memoryAsset, mon
|
||||
return nil, err
|
||||
}
|
||||
pages := make(map[string][]byte)
|
||||
for _, name := range []string{"home.html", "docs.html", "playground.html"} {
|
||||
for _, name := range []string{"home.html", "docs.html", "playground.html", "gltf.html"} {
|
||||
content, err := webFiles.ReadFile("web/" + name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -63,6 +63,8 @@ func (app *application) routes() http.Handler {
|
||||
mux.HandleFunc("/docs/", app.servePage("docs.html"))
|
||||
mux.HandleFunc("/playground", app.servePlayground)
|
||||
mux.HandleFunc("/playground/", app.servePlayground)
|
||||
mux.HandleFunc("/view/gltf", app.servePage("gltf.html"))
|
||||
mux.HandleFunc("/view/gltf/", app.servePage("gltf.html"))
|
||||
mux.HandleFunc("/", app.serveHome)
|
||||
return app.headers(mux)
|
||||
}
|
||||
|
||||
@@ -144,3 +144,24 @@ func TestSavedPlaygroundPageRoute(t *testing.T) {
|
||||
t.Fatalf("content type = %q", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGltfViewerPageRoute(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/view/gltf", nil)
|
||||
response := httptest.NewRecorder()
|
||||
testApplication(t).routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if contentType := response.Header().Get("Content-Type"); contentType != "text/html; charset=utf-8" {
|
||||
t.Fatalf("content type = %q", contentType)
|
||||
}
|
||||
for _, content := range []string{
|
||||
`"@yawn/core": "/pkg/core.js"`,
|
||||
`"@yawn/handles": "/pkg/handles.js"`,
|
||||
"Drop a glTF file",
|
||||
} {
|
||||
if !bytes.Contains(response.Body.Bytes(), []byte(content)) {
|
||||
t.Fatalf("viewer page does not contain %q", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
:root {
|
||||
color: #d7e9f8;
|
||||
background: #06111d;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
.viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer {
|
||||
position: relative;
|
||||
background: #06111d;
|
||||
}
|
||||
|
||||
.viewer::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid transparent;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
.viewer.is-dragging::after {
|
||||
border-color: #35a9ed;
|
||||
background: rgb(20 112 168 / 9%);
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.render-time {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
color: #718da2;
|
||||
border: 1px solid #23435a;
|
||||
border-radius: 7px;
|
||||
background: rgb(8 25 39 / 82%);
|
||||
box-shadow: 0 8px 24px rgb(0 8 15 / 18%);
|
||||
font:
|
||||
700 9px/1 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
monospace;
|
||||
letter-spacing: 0.08em;
|
||||
pointer-events: none;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.render-time[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.render-time strong {
|
||||
color: #8bd5fb;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.drop-prompt {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
min-width: min(360px, calc(100% - 36px));
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
padding: 28px 32px;
|
||||
transform: translate(-50%, -50%);
|
||||
color: inherit;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.drop-prompt:hover,
|
||||
.drop-prompt:focus-visible,
|
||||
.viewer.is-dragging .drop-prompt {
|
||||
border-color: #25577a;
|
||||
background: rgb(11 41 65 / 72%);
|
||||
box-shadow: 0 18px 50px rgb(0 10 18 / 22%);
|
||||
}
|
||||
|
||||
.drop-prompt[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.viewer.is-dragging .drop-prompt {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
display: grid;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 7px;
|
||||
place-items: center;
|
||||
color: #54bdf6;
|
||||
border: 1px solid #25577a;
|
||||
border-radius: 13px;
|
||||
background: #0b2941;
|
||||
box-shadow: 0 0 34px rgb(46 171 238 / 15%);
|
||||
font:
|
||||
700 19px/1 ui-monospace,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.drop-prompt strong {
|
||||
color: #c5d9e8;
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.drop-prompt small {
|
||||
color: #698297;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.drop-prompt.is-error .drop-icon {
|
||||
color: #f0a5a5;
|
||||
border-color: #6f3540;
|
||||
background: #351b27;
|
||||
box-shadow: 0 0 34px rgb(205 78 91 / 12%);
|
||||
}
|
||||
|
||||
.drop-prompt.is-error strong {
|
||||
color: #e8b5b5;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.render-time {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.drop-prompt {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
AmbientLight,
|
||||
ArcRotateCamera,
|
||||
Scene,
|
||||
importGltf,
|
||||
} from "@yawn/handles";
|
||||
|
||||
const elements = {
|
||||
canvas: document.getElementById("canvas"),
|
||||
detail: document.getElementById("prompt-detail"),
|
||||
dropZone: document.getElementById("drop-zone"),
|
||||
filePicker: document.getElementById("file-picker"),
|
||||
message: document.getElementById("prompt-message"),
|
||||
prompt: document.getElementById("drop-prompt"),
|
||||
renderTime: document.getElementById("render-time"),
|
||||
renderValue: document.getElementById("render-value"),
|
||||
};
|
||||
|
||||
let activeViewer;
|
||||
let loading = false;
|
||||
|
||||
function setPrompt(message, detail, error = false) {
|
||||
elements.message.textContent = message;
|
||||
elements.detail.textContent = detail;
|
||||
elements.prompt.classList.toggle("is-error", error);
|
||||
elements.prompt.hidden = false;
|
||||
}
|
||||
|
||||
function resetPrompt() {
|
||||
setPrompt(
|
||||
"Drop a glTF file",
|
||||
"GLB or self-contained glTF · processed locally",
|
||||
);
|
||||
}
|
||||
|
||||
function replaceCanvas() {
|
||||
const canvas = elements.canvas.cloneNode();
|
||||
const scale = Math.min(devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.max(1, Math.round(elements.dropZone.clientWidth * scale));
|
||||
canvas.height = Math.max(
|
||||
1,
|
||||
Math.round(elements.dropZone.clientHeight * scale),
|
||||
);
|
||||
elements.canvas.replaceWith(canvas);
|
||||
elements.canvas = canvas;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function disposeViewer() {
|
||||
if (!activeViewer) return;
|
||||
cancelAnimationFrame(activeViewer.dirtyFrame);
|
||||
activeViewer.stopProfile?.();
|
||||
activeViewer.camera?.detachControls();
|
||||
activeViewer.scene.dispose();
|
||||
activeViewer = undefined;
|
||||
}
|
||||
|
||||
function markDirtyEveryFrame(viewer) {
|
||||
viewer.signals[5] = 1;
|
||||
viewer.dirtyFrame = requestAnimationFrame(() => markDirtyEveryFrame(viewer));
|
||||
}
|
||||
|
||||
function rotate([rx, ry, rz, rw], [x, y, z]) {
|
||||
const tx = 2 * (ry * z - rz * y);
|
||||
const ty = 2 * (rz * x - rx * z);
|
||||
const tz = 2 * (rx * y - ry * x);
|
||||
return [
|
||||
x + rw * tx + ry * tz - rz * ty,
|
||||
y + rw * ty + rz * tx - rx * tz,
|
||||
z + rw * tz + rx * ty - ry * tx,
|
||||
];
|
||||
}
|
||||
|
||||
function findBounds(scene, meshes) {
|
||||
const minimum = [Infinity, Infinity, Infinity];
|
||||
const maximum = [-Infinity, -Infinity, -Infinity];
|
||||
|
||||
for (const mesh of meshes) {
|
||||
const bounds = scene.array("bounds").row(mesh.id);
|
||||
const position = Array.from(mesh.position);
|
||||
const rotor = Array.from(mesh.rotor);
|
||||
const scale = Array.from(mesh.scale);
|
||||
|
||||
for (const x of [bounds[0], bounds[4]])
|
||||
for (const y of [bounds[1], bounds[5]])
|
||||
for (const z of [bounds[2], bounds[6]]) {
|
||||
const corner = rotate(rotor, [
|
||||
x * scale[0],
|
||||
y * scale[1],
|
||||
z * scale[2],
|
||||
]).map((value, lane) => value + position[lane]);
|
||||
for (let lane = 0; lane < 3; lane++) {
|
||||
minimum[lane] = Math.min(minimum[lane], corner[lane]);
|
||||
maximum[lane] = Math.max(maximum[lane], corner[lane]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (![...minimum, ...maximum].every(Number.isFinite))
|
||||
throw new Error("The file does not contain renderable mesh bounds.");
|
||||
|
||||
return {
|
||||
center: minimum.map((value, lane) => (value + maximum[lane]) / 2),
|
||||
extents: maximum.map((value, lane) => value - minimum[lane]),
|
||||
radius: Math.max(
|
||||
0.01,
|
||||
Math.hypot(...maximum.map((value, lane) => value - minimum[lane])) / 2,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function cameraOptions(scene, meshes, canvas, bounds) {
|
||||
const aspect = canvas.width / canvas.height;
|
||||
const verticalFov = meshes.camera?.fov ?? 0.8;
|
||||
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect);
|
||||
let alpha = 5.0861;
|
||||
let beta = 1.5454;
|
||||
if (meshes.camera?.direction) {
|
||||
const [x, y, z] = meshes.camera.direction;
|
||||
alpha = Math.atan2(x, z);
|
||||
beta = Math.acos(Math.max(-1, Math.min(1, y)));
|
||||
} else if (bounds.extents[2] < bounds.extents[0] * 0.75) {
|
||||
alpha = 0;
|
||||
beta = 1.45;
|
||||
} else if (bounds.extents[0] < bounds.extents[2] * 0.75) {
|
||||
alpha = Math.PI / 2;
|
||||
beta = 1.45;
|
||||
}
|
||||
const cameraDirection = [
|
||||
Math.sin(beta) * Math.sin(alpha),
|
||||
Math.cos(beta),
|
||||
Math.sin(beta) * Math.cos(alpha),
|
||||
];
|
||||
const right = [Math.cos(alpha), 0, -Math.sin(alpha)];
|
||||
const up = [
|
||||
-Math.cos(beta) * Math.sin(alpha),
|
||||
Math.sin(beta),
|
||||
-Math.cos(beta) * Math.cos(alpha),
|
||||
];
|
||||
const tangentX = Math.tan(horizontalFov / 2);
|
||||
const tangentY = Math.tan(verticalFov / 2);
|
||||
let distance = 0;
|
||||
|
||||
for (const mesh of meshes) {
|
||||
const positions = scene.geometryData(mesh.geometryId, "positions");
|
||||
const position = Array.from(mesh.position);
|
||||
const rotor = Array.from(mesh.rotor);
|
||||
const scale = Array.from(mesh.scale);
|
||||
for (let index = 0; index < positions.length; index += 3) {
|
||||
const point = rotate(rotor, [
|
||||
positions[index] * scale[0],
|
||||
positions[index + 1] * scale[1],
|
||||
positions[index + 2] * scale[2],
|
||||
]).map((value, lane) => value + position[lane] - bounds.center[lane]);
|
||||
const towardCamera = point.reduce(
|
||||
(value, lane, index) => value + lane * cameraDirection[index],
|
||||
0,
|
||||
);
|
||||
const horizontal = Math.abs(
|
||||
point.reduce((value, lane, index) => value + lane * right[index], 0),
|
||||
);
|
||||
const vertical = Math.abs(
|
||||
point.reduce((value, lane, index) => value + lane * up[index], 0),
|
||||
);
|
||||
distance = Math.max(
|
||||
distance,
|
||||
towardCamera + horizontal / tangentX,
|
||||
towardCamera + vertical / tangentY,
|
||||
);
|
||||
}
|
||||
}
|
||||
distance = Math.max(0.01, distance * 1.08);
|
||||
|
||||
return {
|
||||
targetPosition: bounds.center,
|
||||
alpha,
|
||||
beta,
|
||||
radius: distance,
|
||||
fov: verticalFov,
|
||||
near: Math.max(0.001, (distance - bounds.radius) * 0.25),
|
||||
far: Math.max(1000, distance + bounds.radius * 10),
|
||||
aspect,
|
||||
controls: { element: canvas, pointer: true },
|
||||
};
|
||||
}
|
||||
|
||||
function isGltf(file) {
|
||||
return /\.(?:glb|gltf)$/iu.test(file.name);
|
||||
}
|
||||
|
||||
async function openFile(file) {
|
||||
if (loading) return;
|
||||
if (!file || !isGltf(file)) {
|
||||
setPrompt("That file is not glTF", "Drop a .glb or .gltf file.", true);
|
||||
return;
|
||||
}
|
||||
if (!crossOriginIsolated) {
|
||||
setPrompt(
|
||||
"Cross-origin isolation is unavailable",
|
||||
"This viewer needs COOP and COEP response headers.",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!navigator.gpu) {
|
||||
setPrompt(
|
||||
"WebGPU is unavailable",
|
||||
"Open this page in a browser with WebGPU support.",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
disposeViewer();
|
||||
elements.renderTime.hidden = true;
|
||||
setPrompt("Opening model…", `${file.name} stays on this device.`);
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
let scene;
|
||||
try {
|
||||
const canvas = replaceCanvas();
|
||||
scene = new Scene(canvas, {
|
||||
arenaBytes: 384 * 1024 * 1024,
|
||||
hdr: true,
|
||||
});
|
||||
await scene.ready;
|
||||
scene.array("sceneAccent").row(0).set([1, 1, 1, 1]);
|
||||
|
||||
const meshes = await importGltf(scene, url);
|
||||
if (!meshes.length)
|
||||
throw new Error("The file does not contain any triangle meshes.");
|
||||
|
||||
const bounds = findBounds(scene, meshes);
|
||||
const light = new AmbientLight(scene, {
|
||||
color: [1, 0.94, 0.86],
|
||||
intensity: 0.28,
|
||||
});
|
||||
const camera = new ArcRotateCamera(
|
||||
scene,
|
||||
cameraOptions(scene, meshes, canvas, bounds),
|
||||
);
|
||||
await Promise.all([light.ready, camera.ready]);
|
||||
|
||||
elements.renderValue.textContent = "— ms";
|
||||
elements.renderTime.hidden = false;
|
||||
const stopProfile = scene.core.onProfile((profile) => {
|
||||
elements.renderValue.textContent = `${profile.milliseconds.toFixed(2)} ms`;
|
||||
});
|
||||
const profiling = await scene.core.setProfiler(true);
|
||||
if (!profiling)
|
||||
elements.renderTime.title =
|
||||
"GPU timestamp queries are not supported by this adapter.";
|
||||
else {
|
||||
elements.renderTime.removeAttribute("title");
|
||||
scene.array("sceneAccent").row(0)[3] = 1;
|
||||
}
|
||||
|
||||
activeViewer = {
|
||||
camera,
|
||||
scene,
|
||||
stopProfile,
|
||||
signals: scene.core.array("signals").row(0),
|
||||
};
|
||||
markDirtyEveryFrame(activeViewer);
|
||||
resetPrompt();
|
||||
elements.prompt.hidden = true;
|
||||
} catch (error) {
|
||||
scene?.dispose();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setPrompt("Could not open this model", message, true);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
elements.prompt.addEventListener("click", () => {
|
||||
if (!loading) elements.filePicker.click();
|
||||
});
|
||||
|
||||
elements.filePicker.addEventListener("change", () => {
|
||||
const [file] = elements.filePicker.files;
|
||||
void openFile(file);
|
||||
elements.filePicker.value = "";
|
||||
});
|
||||
|
||||
for (const type of ["dragenter", "dragover"]) {
|
||||
elements.dropZone.addEventListener(type, (event) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
|
||||
elements.dropZone.classList.add("is-dragging");
|
||||
});
|
||||
}
|
||||
|
||||
elements.dropZone.addEventListener("dragleave", (event) => {
|
||||
if (!elements.dropZone.contains(event.relatedTarget))
|
||||
elements.dropZone.classList.remove("is-dragging");
|
||||
});
|
||||
|
||||
elements.dropZone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
elements.dropZone.classList.remove("is-dragging");
|
||||
const [file] = event.dataTransfer?.files ?? [];
|
||||
void openFile(file);
|
||||
});
|
||||
|
||||
addEventListener("beforeunload", disposeViewer);
|
||||
resetPrompt();
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#06111d" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Open a local glTF or GLB file in the Yawn WebGPU viewer."
|
||||
/>
|
||||
<title>glTF Viewer — Yawn</title>
|
||||
<link rel="stylesheet" href="/assets/gltf.css" />
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@yawn/core": "/pkg/core.js",
|
||||
"@yawn/handles": "/pkg/handles.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/assets/gltf.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="drop-zone" class="viewer" aria-label="Yawn glTF viewer">
|
||||
<canvas id="canvas" aria-label="3D model preview"></canvas>
|
||||
|
||||
<output id="render-time" class="render-time" hidden>
|
||||
<span>Render</span>
|
||||
<strong id="render-value">— ms</strong>
|
||||
</output>
|
||||
|
||||
<button id="drop-prompt" class="drop-prompt" type="button">
|
||||
<span class="drop-icon" aria-hidden="true">↓</span>
|
||||
<strong id="prompt-message">Drop a glTF file</strong>
|
||||
<small id="prompt-detail"
|
||||
>GLB or self-contained glTF · processed locally</small
|
||||
>
|
||||
</button>
|
||||
<input
|
||||
id="file-picker"
|
||||
type="file"
|
||||
accept=".glb,.gltf,model/gltf-binary,model/gltf+json"
|
||||
hidden
|
||||
/>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||