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