Enhance CDN packages and playground

Amp-Thread-ID: https://ampcode.com/threads/T-01a02485-5574-707c-bff4-5668d83bee8a
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-22 06:21:39 +00:00
co-authored by heaust
parent 7070799862
commit d95e099fa4
17 changed files with 1870 additions and 247 deletions
+3 -2
View File
@@ -19,8 +19,9 @@ npm run server
``` ```
The server rebuilds `@yawn/core` and `@yawn/handles` at startup and serves the public CDN modules at The server rebuilds `@yawn/core` and `@yawn/handles` at startup and serves the public CDN modules at
`https://yawn.heaust.org/pkg/core.js` and `https://yawn.heaust.org/pkg/handles.js`. Their workers and `https://yawn.heaust.org/pkg/core.js` and `https://yawn.heaust.org/pkg/handles.js`. Handles keeps Core
WASM stay on the CDN automatically, so applications only import the module URLs. The complete editor is at `/playground`; saved revisions use as a separate module dependency instead of bundling it. Map and import both URLs as shown in
`server/examples/basic.html`; their workers and WASM stay on the CDN automatically. The complete editor is at `/playground`; saved revisions use
`/playground/{id}/{revision}` URLs. Set `PORT` or `YAWN_DATABASE` to override the default port and `/playground/{id}/{revision}` URLs. Set `PORT` or `YAWN_DATABASE` to override the default port and
`server/data/yawn.db` database path. `server/data/yawn.db` database path.
+53 -5
View File
@@ -62,10 +62,14 @@ 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 light = vec3<f32>(0.0); var light = vec3<f32>(0.0);
var pointLight = vec3<f32>(0.0);
var spotLight = vec3<f32>(0.0);
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) { 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);
light += 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;
pointLight += contribution;
} }
for (var i = 0u; i < arrayLength(&rectLights) / 3u; i++) { for (var i = 0u; i < arrayLength(&rectLights) / 3u; i++) {
let enabled = rectLights[i * 3u + 1u].z; let enabled = rectLights[i * 3u + 1u].z;
@@ -75,7 +79,9 @@ 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);
light += 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;
spotLight += contribution;
} }
for (var i = 0u; i < arrayLength(&directionalLights) / 2u; i++) { for (var i = 0u; i < arrayLength(&directionalLights) / 2u; i++) {
let enabled = directionalLights[i * 2u + 1u].x; let enabled = directionalLights[i * 2u + 1u].x;
@@ -90,6 +96,12 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
clusters[1] = bitcast<u32>(light.r); clusters[1] = bitcast<u32>(light.r);
clusters[2] = bitcast<u32>(light.g); clusters[2] = bitcast<u32>(light.g);
clusters[3] = bitcast<u32>(light.b); clusters[3] = bitcast<u32>(light.b);
clusters[4] = bitcast<u32>(pointLight.r);
clusters[5] = bitcast<u32>(pointLight.g);
clusters[6] = bitcast<u32>(pointLight.b);
clusters[7] = bitcast<u32>(spotLight.r);
clusters[8] = bitcast<u32>(spotLight.g);
clusters[9] = bitcast<u32>(spotLight.b);
}`; }`;
const basicForwardShader = /* wgsl */ ` const basicForwardShader = /* wgsl */ `
@@ -173,6 +185,8 @@ struct VertexOutput {
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>; @group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>; @group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
@group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<f32>>; @group(0) @binding(7) var<storage, read> cameraMatrices: 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>>;
${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>;" : ""} ${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d<f32>;" : ""}
${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d<f32>;" : ""} ${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d<f32>;" : ""}
@@ -235,10 +249,38 @@ fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
let f0 = mix(vec3(0.04), base.rgb, metallic); let f0 = mix(vec3(0.04), base.rgb, metallic);
let specular = f0 * pow(nDotH, max(2.0, 2.0 / (roughness * roughness) - 2.0)); let specular = f0 * pow(nDotH, max(2.0, 2.0 / (roughness * roughness) - 2.0));
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 ambient = vec3(0.08) + clusterLight; let pointAmbient = vec3<f32>(bitcast<f32>(clusters[4]), bitcast<f32>(clusters[5]), bitcast<f32>(clusters[6]));
let diffuse = base.rgb * (1.0 - metallic) * nDotL; 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));
var pointDiffuse = vec3<f32>(0.0);
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) {
let colorIntensity = pointLights[i * 2u];
let state = pointLights[i * 2u + 1u];
if (state.y == 0.0 || state.x <= 0.0) { continue; }
let offset = positions[i].xyz - input.world;
let distance = length(offset);
let attenuation = pow(max(1.0 - distance / state.x, 0.0), 2.0);
let incidence = max(dot(normal, offset / max(distance, 0.0001)), 0.0);
pointDiffuse += colorIntensity.rgb * colorIntensity.a * attenuation * incidence * 0.08;
}
var spotDiffuse = vec3<f32>(0.0);
for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) {
let colorIntensity = spotLights[i * 2u];
let state = spotLights[i * 2u + 1u];
if (state.w == 0.0 || state.x <= 0.0) { continue; }
let fromLight = input.world - positions[i].xyz;
let distance = length(fromLight);
let lightDirection = normalize(rotate(rotors[i], vec3<f32>(0.0, 0.0, -1.0)));
let cone = smoothstep(cos(state.z), cos(state.y), dot(lightDirection, fromLight / max(distance, 0.0001)));
let attenuation = pow(max(1.0 - distance / state.x, 0.0), 2.0);
let incidence = max(dot(normal, -fromLight / max(distance, 0.0001)), 0.0);
spotDiffuse += colorIntensity.rgb * colorIntensity.a * cone * attenuation * incidence * 0.08;
}
let defaultLight = select(1.0, 0.18, clusters[0] != 0u);
let diffuse = base.rgb * (1.0 - metallic) * nDotL * defaultLight;
let emissive = vec3(properties.z, properties.w, extra.x); let emissive = vec3(properties.z, properties.w, extra.x);
return vec4((base.rgb * ambient + diffuse + specular * nDotL + emissive) * accent.a, base.a); let localDiffuse = base.rgb * (1.0 - metallic) * (pointDiffuse + spotDiffuse);
return vec4((base.rgb * ambient + diffuse + localDiffuse + specular * nDotL * defaultLight + emissive) * accent.a, base.a);
}`; }`;
} }
@@ -1049,6 +1091,12 @@ export class Scene {
binding, binding,
resource, resource,
})), })),
...(detailed
? [
{ group: 0, binding: 8, resource: "point-lights" },
{ group: 0, binding: 9, resource: "spot-lights" },
]
: []),
...(detailed && mask ...(detailed && mask
? [ ? [
{ group: 1, binding: 0, resource: "material-linear" }, { group: 1, binding: 0, resource: "material-linear" },
+1
View File
@@ -2,6 +2,7 @@ export * from "./Scene";
export * from "./ComputePass"; export * from "./ComputePass";
export * from "./Node"; export * from "./Node";
export * from "./Mesh"; export * from "./Mesh";
export * from "./shapes";
export * from "./camera/Camera"; export * from "./camera/Camera";
export * from "./camera/ArcRotateCamera"; export * from "./camera/ArcRotateCamera";
export * from "./camera/FreeCamera"; export * from "./camera/FreeCamera";
+57
View File
@@ -0,0 +1,57 @@
import { Mesh, type MeshOptions } from "../Mesh";
import type { Scene } from "../Scene";
export type CubeOptions = Omit<MeshOptions, "geometryId" | "vertexData"> & {
size?: number;
width?: number;
height?: number;
depth?: number;
};
function dimension(value: number, name: string) {
if (!Number.isFinite(value) || value <= 0) throw new RangeError(name);
return value / 2;
}
/** A six-sided mesh with independent vertices and normals for every face. */
export class Cube extends Mesh {
constructor(scene: Scene, options: CubeOptions = {}) {
const {
size = 1,
width = size,
height = size,
depth = size,
...meshOptions
} = options;
const x = dimension(width, "width");
const y = dimension(height, "height");
const z = dimension(depth, "depth");
const positions = [
[-x, -y, z, x, -y, z, x, y, z, -x, y, z],
[x, -y, -z, -x, -y, -z, -x, y, -z, x, y, -z],
[x, -y, z, x, -y, -z, x, y, -z, x, y, z],
[-x, -y, -z, -x, -y, z, -x, y, z, -x, y, -z],
[-x, y, z, x, y, z, x, y, -z, -x, y, -z],
[-x, -y, -z, x, -y, -z, x, -y, z, -x, -y, z],
].flat();
const normals = [
[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
[0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1],
[1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0],
[-1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0],
[0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0],
[0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0],
].flat();
const uvs = Array.from({ length: 6 }, () => [
0, 0, 1, 0, 1, 1, 0, 1,
]).flat();
const indices = Array.from({ length: 6 }, (_, face) => {
const vertex = face * 4;
return [vertex, vertex + 1, vertex + 2, vertex, vertex + 2, vertex + 3];
}).flat();
super(scene, {
...meshOptions,
vertexData: { positions, normals, uvs, indices },
});
}
}
+100
View File
@@ -0,0 +1,100 @@
import { Mesh, type MeshOptions } from "../Mesh";
import type { Scene } from "../Scene";
export type IcosphereOptions = Omit<
MeshOptions,
"geometryId" | "vertexData"
> & {
radius?: number;
subdivisions?: number;
};
function normalized(vertex: number[]) {
const length = Math.hypot(...vertex);
return vertex.map((lane) => lane / length);
}
/** A sphere produced by recursively subdividing an icosahedron. */
export class Icosphere extends Mesh {
constructor(scene: Scene, options: IcosphereOptions = {}) {
const { radius = 0.5, subdivisions = 2, ...meshOptions } = options;
if (!Number.isFinite(radius) || radius <= 0) throw new RangeError("radius");
if (!Number.isInteger(subdivisions) || subdivisions < 0 || subdivisions > 6)
throw new RangeError("subdivisions");
const golden = (1 + Math.sqrt(5)) / 2;
const vertices = [
[-1, golden, 0],
[1, golden, 0],
[-1, -golden, 0],
[1, -golden, 0],
[0, -1, golden],
[0, 1, golden],
[0, -1, -golden],
[0, 1, -golden],
[golden, 0, -1],
[golden, 0, 1],
[-golden, 0, -1],
[-golden, 0, 1],
].map(normalized);
let faces = [
[0, 11, 5],
[0, 5, 1],
[0, 1, 7],
[0, 7, 10],
[0, 10, 11],
[1, 5, 9],
[5, 11, 4],
[11, 10, 2],
[10, 7, 6],
[7, 1, 8],
[3, 9, 4],
[3, 4, 2],
[3, 2, 6],
[3, 6, 8],
[3, 8, 9],
[4, 9, 5],
[2, 4, 11],
[6, 2, 10],
[8, 6, 7],
[9, 8, 1],
];
for (let level = 0; level < subdivisions; level++) {
const midpoints = new Map<string, number>();
const midpoint = (left: number, right: number) => {
const key = left < right ? `${left}:${right}` : `${right}:${left}`;
const existing = midpoints.get(key);
if (existing !== undefined) return existing;
const vertex = normalized(
vertices[left].map((lane, index) => lane + vertices[right][index]),
);
const index = vertices.push(vertex) - 1;
midpoints.set(key, index);
return index;
};
faces = faces.flatMap(([a, b, c]) => {
const ab = midpoint(a, b);
const bc = midpoint(b, c);
const ca = midpoint(c, a);
return [
[a, ab, ca],
[b, bc, ab],
[c, ca, bc],
[ab, bc, ca],
];
});
}
const normals = vertices.flat();
const positions = normals.map((lane) => lane * radius);
const uvs = vertices.flatMap(([x, y, z]) => [
0.5 + Math.atan2(z, x) / (Math.PI * 2),
0.5 - Math.asin(y) / Math.PI,
]);
super(scene, {
...meshOptions,
vertexData: { positions, normals, uvs, indices: faces.flat() },
});
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Mesh, type MeshOptions } from "../Mesh";
import type { Scene } from "../Scene";
export type PlaneOptions = Omit<MeshOptions, "geometryId" | "vertexData"> & {
size?: number;
width?: number;
depth?: number;
};
function dimension(value: number, name: string) {
if (!Number.isFinite(value) || value <= 0) throw new RangeError(name);
return value / 2;
}
/** A horizontal, upward-facing plane centered on its transform. */
export class Plane extends Mesh {
constructor(scene: Scene, options: PlaneOptions = {}) {
const { size = 1, width = size, depth = size, ...meshOptions } = options;
const x = dimension(width, "width");
const z = dimension(depth, "depth");
super(scene, {
...meshOptions,
vertexData: {
positions: [-x, 0, -z, x, 0, -z, x, 0, z, -x, 0, z],
normals: [0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0],
uvs: [0, 0, 1, 0, 1, 1, 0, 1],
indices: [0, 2, 1, 0, 3, 2],
},
});
}
}
+56
View File
@@ -0,0 +1,56 @@
import { Mesh, type MeshOptions } from "../Mesh";
import type { Scene } from "../Scene";
export type SphereOptions = Omit<MeshOptions, "geometryId" | "vertexData"> & {
radius?: number;
segments?: number;
rings?: number;
};
function count(value: number, minimum: number, name: string) {
if (!Number.isInteger(value) || value < minimum) throw new RangeError(name);
return value;
}
/** A UV sphere with configurable horizontal segments and vertical rings. */
export class Sphere extends Mesh {
constructor(scene: Scene, options: SphereOptions = {}) {
const { radius = 0.5, segments = 32, rings = 16, ...meshOptions } = options;
if (!Number.isFinite(radius) || radius <= 0) throw new RangeError("radius");
const columns = count(segments, 3, "segments");
const rows = count(rings, 2, "rings");
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
for (let ring = 0; ring <= rows; ring++) {
const vertical = ring / rows;
const theta = vertical * Math.PI;
const y = Math.cos(theta);
const scale = Math.sin(theta);
for (let segment = 0; segment <= columns; segment++) {
const horizontal = segment / columns;
const phi = horizontal * Math.PI * 2;
const x = scale * Math.cos(phi);
const z = scale * Math.sin(phi);
normals.push(x, y, z);
positions.push(x * radius, y * radius, z * radius);
uvs.push(horizontal, 1 - vertical);
}
}
for (let ring = 0; ring < rows; ring++) {
for (let segment = 0; segment < columns; segment++) {
const current = ring * (columns + 1) + segment;
const next = current + columns + 1;
indices.push(current, current + 1, next, current + 1, next + 1, next);
}
}
super(scene, {
...meshOptions,
vertexData: { positions, normals, uvs, indices },
});
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./Cube";
export * from "./Icosphere";
export * from "./Plane";
export * from "./Sphere";
+19 -7
View File
@@ -4,7 +4,8 @@ import process from "node:process";
import { build } from "esbuild"; import { build } from "esbuild";
const [root, output] = process.argv.slice(2); const [root, output] = process.argv.slice(2);
if (!root || !output) throw new Error("usage: node build.mjs <repository> <output>"); if (!root || !output)
throw new Error("usage: node build.mjs <repository> <output>");
const browser = { const browser = {
bundle: true, bundle: true,
@@ -17,8 +18,14 @@ const browser = {
const workerFiles = new Map([ const workerFiles = new Map([
[path.join(root, "core", "index.js"), "./worker.js"], [path.join(root, "core", "index.js"), "./worker.js"],
[path.join(root, "addons", "handles", "src", "importers", "gltf.ts"), "./importer-worker.js"], [
[path.join(root, "addons", "handles", "src", "bvh", "picking.ts"), "./bvh-worker.js"], path.join(root, "addons", "handles", "src", "importers", "gltf.ts"),
"./importer-worker.js",
],
[
path.join(root, "addons", "handles", "src", "bvh", "picking.ts"),
"./bvh-worker.js",
],
]); ]);
const workerURLs = { const workerURLs = {
@@ -32,7 +39,8 @@ const workerURLs = {
/new Worker\(new URL\("\.\/worker\.(?:js|ts)", import\.meta\.url\),/, /new Worker\(new URL\("\.\/worker\.(?:js|ts)", import\.meta\.url\),/,
`createYawnWorker(new URL(${JSON.stringify(worker)}, import.meta.url),`, `createYawnWorker(new URL(${JSON.stringify(worker)}, import.meta.url),`,
); );
if (transformed === contents) throw new Error(`worker constructor not found in ${source}`); if (transformed === contents)
throw new Error(`worker constructor not found in ${source}`);
contents = ` contents = `
function createYawnWorker(url, options) { function createYawnWorker(url, options) {
if (url.origin === globalThis.location.origin) return new Worker(url, options); if (url.origin === globalThis.location.origin) return new Worker(url, options);
@@ -57,8 +65,8 @@ await Promise.all([
}), }),
build({ build({
...browser, ...browser,
alias: { "@yawn/core": path.join(root, "core", "index.js") },
entryPoints: [path.join(root, "addons", "handles", "src", "index.ts")], entryPoints: [path.join(root, "addons", "handles", "src", "index.ts")],
external: ["@yawn/core"],
outfile: path.join(output, "handles.js"), outfile: path.join(output, "handles.js"),
plugins: [workerURLs], plugins: [workerURLs],
}), }),
@@ -73,12 +81,16 @@ await Promise.all([
}), }),
build({ build({
...browser, ...browser,
entryPoints: [path.join(root, "addons", "handles", "src", "importers", "worker.ts")], entryPoints: [
path.join(root, "addons", "handles", "src", "importers", "worker.ts"),
],
outfile: path.join(output, "importer-worker.js"), outfile: path.join(output, "importer-worker.js"),
}), }),
build({ build({
...browser, ...browser,
entryPoints: [path.join(root, "addons", "handles", "src", "bvh", "worker.ts")], entryPoints: [
path.join(root, "addons", "handles", "src", "bvh", "worker.ts"),
],
outfile: path.join(output, "bvh-worker.js"), outfile: path.join(output, "bvh-worker.js"),
}), }),
]); ]);
+84
View File
@@ -0,0 +1,84 @@
<!doctype html>
<!-- The host serving this page must send COOP: same-origin and COEP: require-corp headers. -->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Yawn CDN example</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
color: #15304a;
background: #f4faff;
font:
16px/1.5 system-ui,
sans-serif;
}
main {
width: min(720px, calc(100% - 32px));
}
canvas {
width: 100%;
aspect-ratio: 16 / 9;
display: block;
background: white;
}
#status {
margin: 12px 0 0;
}
</style>
<script type="importmap">
{
"imports": {
"@yawn/core": "https://yawn.heaust.org/pkg/core.js",
"@yawn/handles": "https://yawn.heaust.org/pkg/handles.js"
}
}
</script>
</head>
<body>
<main>
<canvas id="view" width="1280" height="720"></canvas>
<p id="status">Starting Yawn…</p>
</main>
<script type="module">
import { YawnCore } from "@yawn/core";
import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
const canvas = document.querySelector("#view");
const status = document.querySelector("#status");
const scene = new Scene(canvas, { hdr: true, fps: 60 });
await scene.ready;
if (!(scene.core instanceof YawnCore)) {
throw new Error(
"Handles did not use the separately imported Core module.",
);
}
const material = new PBRMaterial(scene, {
baseColor: [0.12, 0.58, 1, 1],
metallic: 0.15,
roughness: 0.35,
});
await material.ready;
const triangle = new Mesh(scene, {
material,
vertexData: {
positions: [-0.72, -0.62, 0, 0.72, -0.62, 0, 0, 0.76, 0],
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1],
indices: [0, 1, 2],
},
});
await triangle.ready;
status.textContent = "Scene ready — Core and Handles loaded separately.";
document.documentElement.dataset.ready = "true";
</script>
</body>
</html>
+255 -75
View File
@@ -1,60 +1,98 @@
const defaultSource = `import { const defaultSource = `import {
AmbientLight,
ArcRotateCamera, ArcRotateCamera,
ColorGrading, ColorGrading,
FXAA, FXAA,
Mesh, Icosphere,
PBRMaterial, PBRMaterial,
Plane,
PointLight, PointLight,
Scene, Scene,
SpotLight,
} from "@yawn/handles"; } from "@yawn/handles";
const scene = new Scene(canvas, { hdr: true, fps: 60 }); const scene = new Scene(canvas, { hdr: true, fps: 60 });
await scene.ready; await scene.ready;
await scene.reserve({ nodes: 6, materials: 2 });
const material = new PBRMaterial(scene, { const objectMaterial = new PBRMaterial(scene, {
baseColor: [0.12, 0.58, 1, 1], baseColor: [0.82, 0.86, 0.9, 1],
metallic: 0.15, metallic: 0.12,
roughness: 0.35, roughness: 0.32,
}); });
await material.ready; const groundMaterial = new PBRMaterial(scene, {
baseColor: [0.24, 0.28, 0.34, 1],
const mesh = new Mesh(scene, { metallic: 0,
material, roughness: 0.82,
vertexData: {
positions: [-0.72, -0.62, 0, 0.72, -0.62, 0, 0, 0.76, 0],
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1],
indices: [0, 1, 2],
},
}); });
await mesh.ready; await Promise.all([objectMaterial.ready, groundMaterial.ready]);
log("Scene ready — drag your pointer across the preview."); const icosphere = new Icosphere(scene, {
const move = (event: PointerEvent) => { material: objectMaterial,
const bounds = canvas.getBoundingClientRect(); subdivisions: 2,
mesh.position.x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 1.2; position: [0, 0.2, 0],
mesh.position.y = (0.5 - (event.clientY - bounds.top) / bounds.height) * 0.8; });
}; const ground = new Plane(scene, {
canvas.addEventListener("pointermove", move); material: groundMaterial,
size: 2.6,
position: [0, -0.55, 0],
});
await Promise.all([icosphere.ready, ground.ready]);
icosphere.rotateY(Math.PI / 5);
const camera = new ArcRotateCamera(scene, {
target: icosphere,
alpha: Math.PI / 4,
beta: Math.PI / 3,
radius: 4,
aspect: canvas.width / canvas.height,
controls: { element: canvas, pointer: true },
});
await camera.ready;
const lights = [
new AmbientLight(scene, { color: [0.02, 0.45, 0.48], intensity: 0.5 }),
new SpotLight(scene, {
position: [-2.8, 3, -2.8],
rotor: [-0.628, -0.628, 0, 0.4597],
color: [1, 0.22, 0.03],
intensity: 220,
range: 7,
innerAngle: 0.32,
outerAngle: 0.72,
}),
new PointLight(scene, {
position: [2.2, 0.9, 1.8],
color: [1, 0.02, 0.45],
intensity: 16,
range: 6,
}),
];
await Promise.all(lights.map((light) => light.ready));
log("Icosphere ready — teal ambience, orange rim, and magenta accent are active.");
export default { export default {
scene, scene,
mesh, icosphere,
ground,
lights,
dispose() { dispose() {
canvas.removeEventListener("pointermove", move); camera.detachControls();
scene.dispose(); scene.dispose();
}, },
};`; };`;
const snippets = { const snippets = {
camera: `// Orbit camera — drag to orbit and use the wheel to zoom. camera: `// Orbit camera — drag to orbit and use the wheel to zoom.
const camera = new ArcRotateCamera(scene, { const orbitCamera = new ArcRotateCamera(scene, {
target: mesh, target: icosphere,
alpha: 0, alpha: 0,
beta: Math.PI / 2, beta: Math.PI / 2,
radius: 3, radius: 3,
controls: { element: canvas, pointer: true }, controls: { element: canvas, pointer: true },
}); });
await camera.ready;`, await orbitCamera.ready;`,
light: `// A warm point light backed by shared rows. light: `// A warm point light backed by shared rows.
const light = new PointLight(scene, { const light = new PointLight(scene, {
position: [0, 0.7, 1], position: [0, 0.7, 1],
@@ -65,7 +103,7 @@ const light = new PointLight(scene, {
await light.ready;`, await light.ready;`,
instances: `// Clones share geometry until one clone changes its vertex data. instances: `// Clones share geometry until one clone changes its vertex data.
for (let x = -2; x <= 2; x++) { for (let x = -2; x <= 2; x++) {
const instance = mesh.clone({ position: [x * 0.35, 0, 0] }); const instance = icosphere.clone({ position: [x * 0.7, 0.2, 0] });
await instance.ready; await instance.ready;
}`, }`,
rows: `// Application-owned hot data in the same shared arena. rows: `// Application-owned hot data in the same shared arena.
@@ -123,9 +161,59 @@ const elements = Object.fromEntries(
); );
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const packages = Promise.all([import("/pkg/handles.js"), import("/pkg/core.js")]).then( const nativeSetTimeout = window.setTimeout.bind(window);
([handles, core]) => ({ ...handles, ...core }), const nativeSetInterval = window.setInterval.bind(window);
); const nativeClearTimeout = window.clearTimeout.bind(window);
const nativeClearInterval = window.clearInterval.bind(window);
const playgroundTimerIDs = new Set();
function trackedSetTimeout(callback, delay, ...arguments_) {
let id;
const wrapped =
typeof callback === "function"
? (...callbackArguments) => {
playgroundTimerIDs.delete(id);
callback.apply(window, callbackArguments);
}
: callback;
id = nativeSetTimeout(wrapped, delay, ...arguments_);
playgroundTimerIDs.add(id);
return id;
}
function trackedSetInterval(callback, delay, ...arguments_) {
const id = nativeSetInterval(callback, delay, ...arguments_);
playgroundTimerIDs.add(id);
return id;
}
function trackedClearTimer(id) {
playgroundTimerIDs.delete(id);
nativeClearTimeout(id);
nativeClearInterval(id);
}
function clearPlaygroundTimers() {
for (const id of playgroundTimerIDs) {
nativeClearTimeout(id);
nativeClearInterval(id);
}
playgroundTimerIDs.clear();
}
window.__yawnPlaygroundTimers = {
ids: playgroundTimerIDs,
setTimeout: trackedSetTimeout,
setInterval: trackedSetInterval,
clearTimeout: trackedClearTimer,
clearInterval: trackedClearTimer,
clear: clearPlaygroundTimers,
};
const packages = Promise.all([
import("@yawn/handles"),
import("@yawn/core"),
]).then(([handles, core]) => ({ ...handles, ...core }));
let monaco; let monaco;
let editor; let editor;
let model; let model;
@@ -143,8 +231,14 @@ let frameRequest;
let sampledFrame = 0; let sampledFrame = 0;
let sampledAt = 0; let sampledAt = 0;
addEventListener("yawn-theme-change", (event) => {
monaco?.editor.setTheme(event.detail.resolved === "dark" ? "yawn" : "vs");
});
function savedLocation() { function savedLocation() {
const match = location.pathname.match(/^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/); const match = location.pathname.match(
/^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/,
);
return match ? { id: match[1], revision: Number(match[2]) } : undefined; return match ? { id: match[1], revision: Number(match[2]) } : undefined;
} }
@@ -152,8 +246,11 @@ async function loadInitialPlayground() {
const saved = savedLocation(); const saved = savedLocation();
if (saved) { if (saved) {
try { try {
const response = await fetch(`/api/playgrounds/${saved.id}/${saved.revision}`); const response = await fetch(
if (!response.ok) throw new Error("This saved playground could not be found."); `/api/playgrounds/${saved.id}/${saved.revision}`,
);
if (!response.ok)
throw new Error("This saved playground could not be found.");
const result = await response.json(); const result = await response.json();
currentID = result.id; currentID = result.id;
currentRevision = result.revision; currentRevision = result.revision;
@@ -188,13 +285,19 @@ importScripts(${JSON.stringify(`${monacoBase}vs/base/worker/workerMain.js`)});
); );
window.MonacoEnvironment = { getWorkerUrl: () => workerURL }; window.MonacoEnvironment = { getWorkerUrl: () => workerURL };
window.require.config({ paths: { vs: `${monacoBase}vs` } }); window.require.config({ paths: { vs: `${monacoBase}vs` } });
window.require(["vs/editor/editor.main"], () => resolve(window.monaco), reject); window.require(
["vs/editor/editor.main"],
() => resolve(window.monaco),
reject,
);
}); });
} }
async function createEditor(source) { async function createEditor(source) {
monaco = await loadMonaco(); monaco = await loadMonaco();
const types = await fetch("/assets/yawn.d.ts").then((response) => response.text()); const types = await fetch("/assets/yawn.d.ts").then((response) =>
response.text(),
);
const defaults = monaco.languages.typescript.typescriptDefaults; const defaults = monaco.languages.typescript.typescriptDefaults;
defaults.setEagerModelSync(true); defaults.setEagerModelSync(true);
defaults.setCompilerOptions({ defaults.setCompilerOptions({
@@ -205,7 +308,10 @@ async function createEditor(source) {
strict: true, strict: true,
target: monaco.languages.typescript.ScriptTarget.ESNext, target: monaco.languages.typescript.ScriptTarget.ESNext,
}); });
defaults.setDiagnosticsOptions({ noSemanticValidation: false, noSyntaxValidation: false }); defaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
});
defaults.addExtraLib(types, "file:///types/yawn.d.ts"); defaults.addExtraLib(types, "file:///types/yawn.d.ts");
monaco.editor.defineTheme("yawn", { monaco.editor.defineTheme("yawn", {
base: "vs-dark", base: "vs-dark",
@@ -232,12 +338,17 @@ async function createEditor(source) {
}, },
}); });
elements.editor.textContent = ""; elements.editor.textContent = "";
model = monaco.editor.createModel(source, "typescript", monaco.Uri.parse("file:///playground/scene.ts")); model = monaco.editor.createModel(
source,
"typescript",
monaco.Uri.parse("file:///playground/scene.ts"),
);
editor = monaco.editor.create(elements.editor, { editor = monaco.editor.create(elements.editor, {
model, model,
theme: "yawn", theme: document.documentElement.dataset.theme === "dark" ? "yawn" : "vs",
automaticLayout: true, automaticLayout: true,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontLigatures: true, fontLigatures: true,
fontSize: 12, fontSize: 12,
lineHeight: 20, lineHeight: 20,
@@ -256,11 +367,13 @@ async function createEditor(source) {
run, run,
}); });
editor.onDidChangeCursorPosition(({ position }) => { editor.onDidChangeCursorPosition(({ position }) => {
elements["cursor-status"].textContent = `Ln ${position.lineNumber}, Col ${position.column}`; elements["cursor-status"].textContent =
`Ln ${position.lineNumber}, Col ${position.column}`;
}); });
model.onDidChangeContent(() => { model.onDidChangeContent(() => {
markDirty(true); markDirty(true);
if (!currentID) localStorage.setItem("yawn:playground:draft", model.getValue()); if (!currentID)
localStorage.setItem("yawn:playground:draft", model.getValue());
}); });
monaco.editor.onDidChangeMarkers(([resource]) => { monaco.editor.onDidChangeMarkers(([resource]) => {
if (resource.toString() === model.uri.toString()) updateDiagnostics(); if (resource.toString() === model.uri.toString()) updateDiagnostics();
@@ -272,8 +385,12 @@ async function createEditor(source) {
function updateDiagnostics() { function updateDiagnostics() {
if (!monaco || !model) return; if (!monaco || !model) return;
const markers = monaco.editor.getModelMarkers({ resource: model.uri }); const markers = monaco.editor.getModelMarkers({ resource: model.uri });
const errors = markers.filter((marker) => marker.severity === monaco.MarkerSeverity.Error).length; const errors = markers.filter(
const warnings = markers.filter((marker) => marker.severity === monaco.MarkerSeverity.Warning).length; (marker) => marker.severity === monaco.MarkerSeverity.Error,
).length;
const warnings = markers.filter(
(marker) => marker.severity === monaco.MarkerSeverity.Warning,
).length;
const total = errors + warnings; const total = errors + warnings;
elements["editor-diagnostics"].classList.toggle("has-errors", errors > 0); elements["editor-diagnostics"].classList.toggle("has-errors", errors > 0);
elements["editor-diagnostics"].lastChild.textContent = total elements["editor-diagnostics"].lastChild.textContent = total
@@ -295,7 +412,8 @@ async function transpile() {
const client = await worker(model.uri); const client = await worker(model.uri);
const emitted = await client.getEmitOutput(model.uri.toString()); const emitted = await client.getEmitOutput(model.uri.toString());
const output = emitted.outputFiles.find((file) => file.name.endsWith(".js")); const output = emitted.outputFiles.find((file) => file.name.endsWith(".js"));
if (emitted.emitSkipped || !output) throw new Error("TypeScript could not emit this scene."); if (emitted.emitSkipped || !output)
throw new Error("TypeScript could not emit this scene.");
return output.text return output.text
.replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "") .replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "")
.replace(/^\s*export\s*\{\s*\};?\s*$/gm, "") .replace(/^\s*export\s*\{\s*\};?\s*$/gm, "")
@@ -329,33 +447,56 @@ function replaceCanvas() {
canvas.setAttribute("aria-label", "Yawn WebGPU preview"); canvas.setAttribute("aria-label", "Yawn WebGPU preview");
const scale = Math.min(devicePixelRatio || 1, 2); const scale = Math.min(devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(elements.preview.clientWidth * scale)); canvas.width = Math.max(1, Math.round(elements.preview.clientWidth * scale));
canvas.height = Math.max(1, Math.round(elements.preview.clientHeight * scale)); canvas.height = Math.max(
1,
Math.round(elements.preview.clientHeight * scale),
);
previous.replaceWith(canvas); previous.replaceWith(canvas);
elements.canvas = canvas; elements.canvas = canvas;
elements["resolution-status"].textContent = `${canvas.width} × ${canvas.height}`; elements["resolution-status"].textContent =
`${canvas.width} × ${canvas.height}`;
return canvas; return canvas;
} }
async function run() { async function run() {
if (!editor || elements["run-button"].disabled) return; if (!editor || elements["run-button"].disabled) return;
clearPlaygroundTimers();
const runID = ++generation; const runID = ++generation;
elements["run-button"].disabled = true; elements["run-button"].disabled = true;
elements["run-button"].lastChild.textContent = " Running"; elements["run-button"].lastChild.textContent = " Running";
clearConsole(); clearConsole();
setRunStatus("Compiling", "running"); setRunStatus("Compiling", "running");
showPreviewMessage("Compiling TypeScript", "Checking types and preparing the render graph."); showPreviewMessage(
"Compiling TypeScript",
"Checking types and preparing the render graph.",
);
try { try {
if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled on this page."); if (!crossOriginIsolated)
if (!navigator.gpu) throw new Error("WebGPU is not available in this browser."); throw new Error("Cross-origin isolation is disabled on this page.");
if (!navigator.gpu)
throw new Error("WebGPU is not available in this browser.");
const [code, api] = await Promise.all([transpile(), packages]); const [code, api] = await Promise.all([transpile(), packages]);
await disposeCurrent(); await disposeCurrent();
const canvas = replaceCanvas(); const canvas = replaceCanvas();
const names = Object.keys(api); const names = Object.keys(api);
const started = performance.now(); const started = performance.now();
const result = await new AsyncFunction(...names, "canvas", "log", code)( const result = await new AsyncFunction(
...names,
"canvas",
"log",
"setTimeout",
"setInterval",
"clearTimeout",
"clearInterval",
code,
)(
...names.map((name) => api[name]), ...names.map((name) => api[name]),
canvas, canvas,
(...values) => appendConsole(values.map(formatValue).join(" ")), (...values) => appendConsole(values.map(formatValue).join(" ")),
trackedSetTimeout,
trackedSetInterval,
trackedClearTimer,
trackedClearTimer,
); );
if (runID !== generation) { if (runID !== generation) {
if (result?.dispose) await result.dispose(); if (result?.dispose) await result.dispose();
@@ -364,7 +505,10 @@ async function run() {
current = result; current = result;
window.__yawnPlayground = result; window.__yawnPlayground = result;
elements["preview-message"].hidden = true; elements["preview-message"].hidden = true;
setRunStatus(`Running · ${Math.round(performance.now() - started)} ms`, "ready"); setRunStatus(
`Running · ${Math.round(performance.now() - started)} ms`,
"ready",
);
if (activePanel === "profile") await attachProfiler(); if (activePanel === "profile") await attachProfiler();
} catch (error) { } catch (error) {
if (runID === generation) { if (runID === generation) {
@@ -377,7 +521,8 @@ async function run() {
} finally { } finally {
if (runID === generation) { if (runID === generation) {
elements["run-button"].disabled = false; elements["run-button"].disabled = false;
elements["run-button"].innerHTML = '<span aria-hidden="true">▶</span> Run <kbd>Ctrl ↵</kbd>'; elements["run-button"].innerHTML =
'<span aria-hidden="true">▶</span> Run <kbd>Ctrl ↵</kbd>';
} }
} }
} }
@@ -396,7 +541,10 @@ async function attachProfiler() {
} }
stopProfile = profileCore.onProfile(renderProfile); stopProfile = profileCore.onProfile(renderProfile);
const supported = await profileCore.setProfiler(true); const supported = await profileCore.setProfiler(true);
if (!supported) setProfileMessage("Timestamp queries are not supported by this GPU adapter."); if (!supported)
setProfileMessage(
"Timestamp queries are not supported by this GPU adapter.",
);
} }
function setProfileMessage(message) { function setProfileMessage(message) {
@@ -408,8 +556,10 @@ function setProfileMessage(message) {
function renderProfile(profile) { function renderProfile(profile) {
elements["profile-empty"].hidden = true; elements["profile-empty"].hidden = true;
elements["profile-results"].hidden = false; elements["profile-results"].hidden = false;
elements["profile-total"].textContent = `${profile.milliseconds.toFixed(2)} ms`; elements["profile-total"].textContent =
elements["profile-latency"].textContent = `${profile.readbackMilliseconds.toFixed(2)} ms`; `${profile.milliseconds.toFixed(2)} ms`;
elements["profile-latency"].textContent =
`${profile.readbackMilliseconds.toFixed(2)} ms`;
elements["profile-adapter"].textContent = profile.adapter || "WebGPU adapter"; elements["profile-adapter"].textContent = profile.adapter || "WebGPU adapter";
elements["profile-adapter"].title = profile.adapter || "WebGPU adapter"; elements["profile-adapter"].title = profile.adapter || "WebGPU adapter";
elements["profile-passes"].replaceChildren( elements["profile-passes"].replaceChildren(
@@ -441,7 +591,8 @@ function appendConsole(message, error = false) {
line.textContent = message; line.textContent = message;
line.classList.toggle("error", error); line.classList.toggle("error", error);
elements["console-lines"].append(line); elements["console-lines"].append(line);
elements["console-count"].textContent = elements["console-lines"].children.length; elements["console-count"].textContent =
elements["console-lines"].children.length;
line.scrollIntoView({ block: "nearest" }); line.scrollIntoView({ block: "nearest" });
} }
@@ -452,7 +603,8 @@ function clearConsole() {
} }
function setRunStatus(message, state) { function setRunStatus(message, state) {
elements["run-status"].className = state === "running" ? "running" : state === "failed" ? "failed" : ""; elements["run-status"].className =
state === "running" ? "running" : state === "failed" ? "failed" : "";
elements["run-status"].lastChild.textContent = ` ${message}`; elements["run-status"].lastChild.textContent = ` ${message}`;
} }
@@ -466,7 +618,9 @@ function openInspector(panel) {
activePanel = panel; activePanel = panel;
elements.inspector.classList.remove("collapsed"); elements.inspector.classList.remove("collapsed");
elements["inspector-button"].setAttribute("aria-pressed", "true"); elements["inspector-button"].setAttribute("aria-pressed", "true");
for (const button of document.querySelectorAll(".inspector-tabs [data-panel]")) { for (const button of document.querySelectorAll(
".inspector-tabs [data-panel]",
)) {
const active = button.dataset.panel === panel; const active = button.dataset.panel === panel;
button.classList.toggle("active", active); button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active)); button.setAttribute("aria-selected", String(active));
@@ -497,7 +651,8 @@ async function save() {
}), }),
}); });
const result = await response.json(); const result = await response.json();
if (!response.ok) throw new Error(result.error || "The playground could not be saved."); if (!response.ok)
throw new Error(result.error || "The playground could not be saved.");
currentID = result.id; currentID = result.id;
currentRevision = result.revision; currentRevision = result.revision;
initialSource = result.code; initialSource = result.code;
@@ -518,13 +673,22 @@ function showToast(message) {
clearTimeout(toastTimer); clearTimeout(toastTimer);
elements.toast.textContent = message; elements.toast.textContent = message;
elements.toast.classList.add("visible"); elements.toast.classList.add("visible");
toastTimer = setTimeout(() => elements.toast.classList.remove("visible"), 2400); toastTimer = setTimeout(
() => elements.toast.classList.remove("visible"),
2400,
);
} }
function insertSnippet(name) { function insertSnippet(name) {
const selection = editor.getSelection(); const selection = editor.getSelection();
const prefix = model.getValueInRange(selection).endsWith("\n") ? "" : "\n\n"; const prefix = model.getValueInRange(selection).endsWith("\n") ? "" : "\n\n";
editor.executeEdits("snippet", [{ range: selection, text: `${prefix}${snippets[name]}\n`, forceMoveMarkers: true }]); editor.executeEdits("snippet", [
{
range: selection,
text: `${prefix}${snippets[name]}\n`,
forceMoveMarkers: true,
},
]);
elements["snippets-dialog"].close(); elements["snippets-dialog"].close();
editor.focus(); editor.focus();
} }
@@ -539,7 +703,8 @@ function sampleFPS(now = performance.now()) {
sampledFrame = frame; sampledFrame = frame;
} else if (now - sampledAt >= 500) { } else if (now - sampledAt >= 500) {
const fps = ((frame - sampledFrame) * 1000) / (now - sampledAt); const fps = ((frame - sampledFrame) * 1000) / (now - sampledAt);
elements["fps-status"].textContent = `${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`; elements["fps-status"].textContent =
`${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`;
sampledAt = now; sampledAt = now;
sampledFrame = frame; sampledFrame = frame;
} }
@@ -562,22 +727,34 @@ elements["reset-button"].addEventListener("click", () => {
model?.setValue(initialSource); model?.setValue(initialSource);
void run(); void run();
}); });
elements["format-button"].addEventListener("click", () => editor?.getAction("editor.action.formatDocument").run()); elements["format-button"].addEventListener("click", () =>
elements["snippets-button"].addEventListener("click", () => elements["snippets-dialog"].showModal()); editor?.getAction("editor.action.formatDocument").run(),
elements["snippets-dialog"].querySelector("[data-close-dialog]").addEventListener("click", () => elements["snippets-dialog"].close()); );
elements["snippets-button"].addEventListener("click", () =>
elements["snippets-dialog"].showModal(),
);
elements["snippets-dialog"]
.querySelector("[data-close-dialog]")
.addEventListener("click", () => elements["snippets-dialog"].close());
elements["snippets-dialog"].addEventListener("click", (event) => { elements["snippets-dialog"].addEventListener("click", (event) => {
if (event.target === elements["snippets-dialog"]) elements["snippets-dialog"].close(); if (event.target === elements["snippets-dialog"])
elements["snippets-dialog"].close();
}); });
for (const button of document.querySelectorAll("[data-snippet]")) { for (const button of document.querySelectorAll("[data-snippet]")) {
button.addEventListener("click", () => insertSnippet(button.dataset.snippet)); button.addEventListener("click", () => insertSnippet(button.dataset.snippet));
} }
elements["snippets-dialog"].querySelector("input").addEventListener("input", (event) => { elements["snippets-dialog"]
const query = event.target.value.trim().toLowerCase(); .querySelector("input")
for (const button of document.querySelectorAll("[data-snippet]")) { .addEventListener("input", (event) => {
button.hidden = Boolean(query) && !button.textContent.toLowerCase().includes(query); const query = event.target.value.trim().toLowerCase();
} for (const button of document.querySelectorAll("[data-snippet]")) {
}); button.hidden =
for (const button of document.querySelectorAll(".inspector-tabs [data-panel]")) { Boolean(query) && !button.textContent.toLowerCase().includes(query);
}
});
for (const button of document.querySelectorAll(
".inspector-tabs [data-panel]",
)) {
button.addEventListener("click", () => openInspector(button.dataset.panel)); button.addEventListener("click", () => openInspector(button.dataset.panel));
} }
elements["clear-console"].addEventListener("click", clearConsole); elements["clear-console"].addEventListener("click", clearConsole);
@@ -598,7 +775,10 @@ elements["resize-handle"].addEventListener("pointerdown", (event) => {
elements["resize-handle"].addEventListener("pointermove", (event) => { elements["resize-handle"].addEventListener("pointermove", (event) => {
if (!elements["resize-handle"].hasPointerCapture(event.pointerId)) return; if (!elements["resize-handle"].hasPointerCapture(event.pointerId)) return;
const bounds = elements.workbench.getBoundingClientRect(); const bounds = elements.workbench.getBoundingClientRect();
const percentage = Math.min(70, Math.max(30, ((event.clientX - bounds.left) / bounds.width) * 100)); const percentage = Math.min(
70,
Math.max(30, ((event.clientX - bounds.left) / bounds.width) * 100),
);
elements.workbench.style.setProperty("--editor-width", `${percentage}%`); elements.workbench.style.setProperty("--editor-width", `${percentage}%`);
}); });
elements["resize-handle"].addEventListener("pointerup", (event) => { elements["resize-handle"].addEventListener("pointerup", (event) => {
+457
View File
@@ -0,0 +1,457 @@
.theme-select {
width: 72px;
height: 32px;
padding: 0 7px;
color: #53667c;
border: 1px solid #d4e1ea;
border-radius: 6px;
outline: 0;
background: #fff;
cursor: pointer;
font-size: 11px;
font-weight: 650;
}
.theme-select:hover {
color: #177fc2;
border-color: #add5ee;
}
.theme-select:focus-visible {
border-color: #62b8eb;
box-shadow: 0 0 0 3px rgb(50 166 232 / 14%);
}
.tool-context {
font-size: 10px;
}
.stage-meta,
.editor-status {
font-size: 9px;
}
html[data-theme="dark"] {
--ink: #d9e9f7;
--muted: #91a6b9;
--line: #20384b;
--line-strong: #315069;
--sky-25: #0a1927;
--sky-50: #0d2132;
--sky-100: #12324a;
--sky-200: #1d4c6a;
--navy: #06111d;
}
html[data-theme="dark"] body {
color: var(--ink);
background: #081522;
}
html[data-theme="dark"] .theme-select {
color: #b4c8d8;
border-color: #29465d;
background: #0e2232;
}
html[data-theme="dark"] .site-header {
border-color: var(--line);
}
html[data-theme="dark"] .wordmark {
color: #e1eff9;
}
html[data-theme="dark"] .site-nav a,
html[data-theme="dark"] .site-footer nav a {
color: #91a7ba;
}
html[data-theme="dark"] .button-quiet {
color: #b9d1e2;
border-color: #315069;
background: #0d2030;
}
html[data-theme="dark"] .button-quiet:hover {
color: #72c8f7;
background: #102a3e;
}
html[data-theme="dark"] .hero::before {
background: radial-gradient(circle, rgb(30 102 148 / 35%), transparent 67%);
}
html[data-theme="dark"] .hero h1 {
color: #edf7ff;
}
html[data-theme="dark"] .hero-note {
color: #718ba0;
}
html[data-theme="dark"] .hero-demo {
border-color: #2a4a61;
background: #0a1927;
box-shadow: 0 30px 70px rgb(0 0 0 / 28%);
}
html[data-theme="dark"] .demo-toolbar {
color: #819bb0;
border-color: #284257;
background: #0d2131;
}
html[data-theme="dark"] .hero-demo pre {
color: #b6cad9;
background: #091827;
}
html[data-theme="dark"] .trust-strip {
background: #0a1927;
}
html[data-theme="dark"] .trust-strip strong {
color: #d7e8f5;
}
html[data-theme="dark"] .trust-strip span {
color: #8298ab;
}
html[data-theme="dark"] .gear-card {
border-color: #20394c;
background: #0b1b29;
box-shadow: 0 8px 30px rgb(0 0 0 / 16%);
}
html[data-theme="dark"] .gear-card-featured {
border-color: #285876;
background: linear-gradient(145deg, #0e2940, #0b1b29);
}
html[data-theme="dark"] .gear-number {
color: #52728a;
}
html[data-theme="dark"] .gear-icon {
border-color: #285675;
background: #0e2a40;
}
html[data-theme="dark"] .gear-card h3 {
color: #d9eaf6;
}
html[data-theme="dark"] .gear-card p {
color: #91a5b6;
}
html[data-theme="dark"] .graph-icon::before,
html[data-theme="dark"] .graph-icon::after {
background: #0e2a40;
}
html[data-theme="dark"] .steps h3 {
color: #d5e6f3;
}
html[data-theme="dark"] .steps p {
color: #8fa4b6;
}
html[data-theme="dark"] .steps code {
color: #6fc3f1;
border-color: #29465c;
background: #0c2030;
}
html[data-theme="dark"] .step-index {
border-color: #285b7a;
background: #0e2a40;
}
html[data-theme="dark"] .site-footer p {
color: #738da1;
}
html[data-theme="dark"] .docs-body {
background: #081522;
}
html[data-theme="dark"] .docs-header {
border-color: #20384b;
background: rgb(8 21 34 / 94%);
}
html[data-theme="dark"] .docs-product {
color: #8097aa;
border-color: #294257;
}
html[data-theme="dark"] .docs-search {
color: #7891a5;
border-color: #29465b;
background: #0d2030;
}
html[data-theme="dark"] .docs-search input {
color: #d6e7f4;
}
html[data-theme="dark"] .docs-search kbd {
color: #8299ab;
border-color: #304c61;
background: #102536;
}
html[data-theme="dark"] .docs-sidebar {
border-color: #20384b;
}
html[data-theme="dark"] .sidebar-group > strong,
html[data-theme="dark"] .docs-toc > strong {
color: #708ba0;
}
html[data-theme="dark"] .sidebar-group a {
color: #91a5b7;
}
html[data-theme="dark"] .sidebar-group a:hover {
color: #69c4f5;
background: #0d2436;
}
html[data-theme="dark"] .sidebar-group a.active {
color: #70cbfb;
background: #10304a;
}
html[data-theme="dark"] .sidebar-playground {
color: #8bd4fa;
border-color: #285572;
background: #0d2940;
}
html[data-theme="dark"] .sidebar-playground small {
color: #7697ad;
}
html[data-theme="dark"] .docs-main section + section {
border-color: #20384b;
}
html[data-theme="dark"] .docs-main h1,
html[data-theme="dark"] .docs-main h2,
html[data-theme="dark"] .docs-main h3 {
color: #e0edf7;
}
html[data-theme="dark"] .docs-main p,
html[data-theme="dark"] .docs-main li {
color: #9aafc0;
}
html[data-theme="dark"] .docs-main strong {
color: #c4d8e7;
}
html[data-theme="dark"] .doc-lede {
color: #a9bdcc !important;
}
html[data-theme="dark"] .docs-main :not(pre) > code {
color: #71c6f3;
border-color: #28506a;
background: #0d293d;
}
html[data-theme="dark"] .section-kicker span {
border-color: #285a78;
background: #0e2b42;
}
html[data-theme="dark"] .callout {
border-color: #294052;
background: #0c1c2a;
}
html[data-theme="dark"] .callout-blue {
border-color: #285672;
background: #0d293e;
}
html[data-theme="dark"] .explain-grid > div {
border-color: #263f52;
background: #0b1c2a;
}
html[data-theme="dark"] .inline-play {
color: #8bd5fb;
border-color: #285974;
background: #0e2c43;
}
html[data-theme="dark"] .inline-play strong {
color: #a2defb;
}
html[data-theme="dark"] .inline-play small {
color: #789bb1;
}
html[data-theme="dark"] .mental-model > div {
border-color: #284356;
background: #0b1d2b;
}
html[data-theme="dark"] .mental-model strong {
color: #b9d0e0;
}
html[data-theme="dark"] .mental-model .mental-shared {
border-color: #2d6585;
background: #0e2c43;
}
html[data-theme="dark"] .deploy-list li {
border-color: #20384b;
}
html[data-theme="dark"] .docs-toc a {
color: #768ea1;
border-color: #294257;
}
html[data-theme="dark"] .playground-app {
background: #07131f;
}
html[data-theme="dark"] .playground-header {
border-color: #21384b;
background: #0b1b29;
}
html[data-theme="dark"] .playground-brand {
color: #dcebf6;
}
html[data-theme="dark"] .header-divider {
background: #294154;
}
html[data-theme="dark"] .project-name input {
color: #c3d6e4;
}
html[data-theme="dark"] .project-name input:hover {
background: #102536;
}
html[data-theme="dark"] .project-name input:focus {
border-color: #2d7198;
background: #0e2638;
}
html[data-theme="dark"] #revision-label {
color: #8298aa;
border-color: #294356;
background: #0e2131;
}
html[data-theme="dark"] .playground-nav a {
color: #8da3b5;
}
html[data-theme="dark"] .tool-button {
color: #a7bdcd;
border-color: #29465c;
background: #0e2232;
}
html[data-theme="dark"] .tool-button:hover {
color: #67c4f4;
border-color: #326484;
background: #102a3d;
}
html[data-theme="dark"] .playground-tools {
border-color: #233b4e;
background: #0c1d2b;
}
html[data-theme="dark"] .tool-group button {
color: #8fa6b8;
}
html[data-theme="dark"] .tool-group button:hover,
html[data-theme="dark"] .tool-group button[aria-pressed="true"] {
color: #63c3f5;
border-color: #2b4d64;
background: #102536;
}
html[data-theme="dark"] .tool-context,
html[data-theme="dark"] .stage-meta,
html[data-theme="dark"] .editor-status {
color: #aec1cf;
}
html[data-theme="dark"] .context-divider {
background: #294154;
}
html[data-theme="dark"] .snippets-dialog {
color: #d3e3ee;
border-color: #2a4a60;
background: #0b1b29;
}
html[data-theme="dark"] .dialog-heading p {
color: #8399aa;
}
html[data-theme="dark"] .snippet-search {
color: #7890a3;
border-color: #29465b;
background: #0d2131;
}
html[data-theme="dark"] .snippet-search input {
color: #c9dae7;
}
html[data-theme="dark"] .snippet-grid > button:hover {
border-color: #2d5772;
background: #0e293d;
}
html[data-theme="dark"] .snippet-icon {
border-color: #2a5a77;
background: #0e2c43;
}
html[data-theme="dark"] .snippet-grid strong {
color: #c9dce9;
}
html[data-theme="dark"] .snippet-grid small {
color: #8298aa;
}
html[data-theme="dark"] .snippet-grid code {
color: #6bc4f2;
border-color: #29475d;
background: #0d2131;
}
html[data-theme="light"] .workbench {
background: #d7e4ed;
}
html[data-theme="light"] .pane-tabs,
html[data-theme="light"] .stage-toolbar {
border-color: #cfdee8;
background: #edf4f8;
}
html[data-theme="light"] .pane-tab {
color: #60778a;
border-color: #d5e2ea;
background: #e8f0f5;
}
html[data-theme="light"] .pane-tab.active {
color: #29465e;
background: #f8fbfd;
}
html[data-theme="light"] .editor {
background: #fff;
}
html[data-theme="light"] .monaco-editor .margin {
background-color: #fff !important;
}
html[data-theme="light"] .editor-status {
color: #718698;
border-color: #d2e0e9;
background: #eff5f8;
}
html[data-theme="light"] .resize-handle {
background: #c8d8e3;
}
html[data-theme="light"] .resize-handle::after {
background: #8fa9bb;
}
html[data-theme="light"] .stage-pane {
background: #e7f0f5;
}
html[data-theme="light"] .stage-tabs button {
color: #60778a;
}
html[data-theme="light"] .stage-tabs button.active {
color: #284961;
background: #f8fbfd;
}
html[data-theme="light"] .stage-meta {
color: #6a8194;
}
html[data-theme="light"] .stage-divider {
background: #c5d7e2;
}
html[data-theme="light"] .inspector {
border-color: #cddde7;
background: #f7fafc;
}
html[data-theme="light"] .inspector-tabs {
border-color: #cfdee7;
background: #edf4f8;
}
html[data-theme="light"] .inspector-tabs button {
color: #687f91;
}
html[data-theme="light"] .inspector-tabs button.active {
color: #287aa9;
}
html[data-theme="light"] .inspector-tabs button span {
background: #dce9f1;
}
html[data-theme="light"] .console-empty,
html[data-theme="light"] .profile-empty {
color: #6f8799;
}
html[data-theme="light"] #console-lines {
color: #526c80;
}
html[data-theme="light"] #console-lines li {
border-color: rgb(190 207 218 / 60%);
}
html[data-theme="light"] .profile-summary > div {
border-color: #d3e1e9;
background: #eef5f8;
}
html[data-theme="light"] .profile-summary strong {
color: #3c6e8d;
}
html[data-theme="light"] .profile-results th,
html[data-theme="light"] .profile-results td {
border-color: #d5e2e9;
}
@media (max-width: 700px) {
.site-header .theme-select {
width: 64px;
}
}
+42
View File
@@ -0,0 +1,42 @@
(() => {
const key = "yawn:theme";
const preference = matchMedia("(prefers-color-scheme: dark)");
const saved = localStorage.getItem(key);
let mode = ["auto", "light", "dark"].includes(saved) ? saved : "auto";
const apply = () => {
const resolved =
mode === "auto" ? (preference.matches ? "dark" : "light") : mode;
document.documentElement.dataset.theme = resolved;
document.documentElement.dataset.themeMode = mode;
document.documentElement.style.colorScheme = resolved;
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", resolved === "dark" ? "#081522" : "#f7fbff");
for (const select of document.querySelectorAll("[data-theme-select]"))
select.value = mode;
dispatchEvent(
new CustomEvent("yawn-theme-change", { detail: { mode, resolved } }),
);
};
const bind = () => {
for (const select of document.querySelectorAll("[data-theme-select]")) {
select.value = mode;
select.addEventListener("change", () => {
mode = select.value;
if (mode === "auto") localStorage.removeItem(key);
else localStorage.setItem(key, mode);
apply();
});
}
};
preference.addEventListener("change", () => {
if (mode === "auto") apply();
});
apply();
if (document.readyState === "loading")
addEventListener("DOMContentLoaded", bind, { once: true });
else bind();
})();
+188 -25
View File
@@ -30,10 +30,25 @@ declare module "@yawn/core" {
passes: Array<{ name: string; milliseconds: number }>; passes: Array<{ name: string; milliseconds: number }>;
} }
export class YawnCore { export class YawnCore {
constructor(canvas: HTMLCanvasElement, options?: { arenaBytes?: number; debug?: boolean }); constructor(
canvas: HTMLCanvasElement,
options?: { arenaBytes?: number; debug?: boolean },
);
readonly ready: Promise<void>; readonly ready: Promise<void>;
createRows(options: { name: string; rows: number; stride: number; format: RowFormat }): Promise<SharedRows>; createRows(options: {
createRowsBatch(rows: Array<{ name: string; rows: number; stride: number; format: RowFormat }>): Promise<SharedRows[]>; name: string;
rows: number;
stride: number;
format: RowFormat;
}): Promise<SharedRows>;
createRowsBatch(
rows: Array<{
name: string;
rows: number;
stride: number;
format: RowFormat;
}>,
): Promise<SharedRows[]>;
deleteRows(name: string): Promise<void>; deleteRows(name: string): Promise<void>;
allocateObject(name: string): Promise<number>; allocateObject(name: string): Promise<number>;
deleteObject(name: string, id: number): Promise<void>; deleteObject(name: string, id: number): Promise<void>;
@@ -52,23 +67,44 @@ declare module "@yawn/core" {
declare module "@yawn/handles" { declare module "@yawn/handles" {
import { SharedRows, YawnCore } from "@yawn/core"; import { SharedRows, YawnCore } from "@yawn/core";
export interface SceneOptions { arenaBytes?: number; debug?: boolean; fps?: number; hdr?: boolean } export interface SceneOptions {
arenaBytes?: number;
debug?: boolean;
fps?: number;
hdr?: boolean;
}
export class Scene { export class Scene {
constructor(canvas: HTMLCanvasElement, options?: SceneOptions); constructor(canvas: HTMLCanvasElement, options?: SceneOptions);
readonly core: YawnCore; readonly core: YawnCore;
readonly ready: Promise<this>; readonly ready: Promise<this>;
readonly hdr: boolean; readonly hdr: boolean;
array(name: string): SharedRows; array(name: string): SharedRows;
ensureRows(name: string, rows: number, stride: number, format: "f32" | "u32" | "i32"): Promise<SharedRows>; ensureRows(
name: string,
rows: number,
stride: number,
format: "f32" | "u32" | "i32",
): Promise<SharedRows>;
reserve(additional: { nodes?: number; materials?: number }): Promise<void>; reserve(additional: { nodes?: number; materials?: number }): Promise<void>;
batchWrites<T>(operation: () => T): T; batchWrites<T>(operation: () => T): T;
batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>; batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>;
dispose(): void; dispose(): void;
} }
export interface Vector3View { x: number; y: number; z: number; set(values: ArrayLike<number>): void } export interface Vector3View {
export interface RotorView extends Vector3View { w: number } x: number;
export interface NodeOptions { position?: ArrayLike<number>; rotor?: ArrayLike<number>; scale?: ArrayLike<number> } y: number;
z: number;
set(values: ArrayLike<number>): void;
}
export interface RotorView extends Vector3View {
w: number;
}
export interface NodeOptions {
position?: ArrayLike<number>;
rotor?: ArrayLike<number>;
scale?: ArrayLike<number>;
}
export class Node { export class Node {
constructor(scene: Scene, options?: NodeOptions); constructor(scene: Scene, options?: NodeOptions);
readonly scene: Scene; readonly scene: Scene;
@@ -103,7 +139,13 @@ declare module "@yawn/handles" {
dispose(): Promise<void>; dispose(): Promise<void>;
} }
export type VertexKind = "positions" | "normals" | "tangents" | "uvs" | "colors" | "indices"; export type VertexKind =
| "positions"
| "normals"
| "tangents"
| "uvs"
| "colors"
| "indices";
export interface MeshOptions extends NodeOptions { export interface MeshOptions extends NodeOptions {
geometryId?: number; geometryId?: number;
material?: PBRMaterial; material?: PBRMaterial;
@@ -119,10 +161,55 @@ declare module "@yawn/handles" {
material: PBRMaterial; material: PBRMaterial;
clone(options?: Omit<MeshOptions, "geometryId" | "vertexData">): Mesh; clone(options?: Omit<MeshOptions, "geometryId" | "vertexData">): Mesh;
setVertexData(kind: VertexKind, data: ArrayLike<number>): Promise<this>; setVertexData(kind: VertexKind, data: ArrayLike<number>): Promise<this>;
setMaterialForFaces(material: PBRMaterial, faces: number | number[]): Promise<this>; setMaterialForFaces(
material: PBRMaterial,
faces: number | number[],
): Promise<this>;
} }
export interface CameraOptions extends NodeOptions { fov?: number; near?: number; far?: number; aspect?: number } export interface CubeOptions
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
size?: number;
width?: number;
height?: number;
depth?: number;
}
export class Cube extends Mesh {
constructor(scene: Scene, options?: CubeOptions);
}
export interface IcosphereOptions
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
radius?: number;
subdivisions?: number;
}
export class Icosphere extends Mesh {
constructor(scene: Scene, options?: IcosphereOptions);
}
export interface PlaneOptions
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
size?: number;
width?: number;
depth?: number;
}
export class Plane extends Mesh {
constructor(scene: Scene, options?: PlaneOptions);
}
export interface SphereOptions
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
radius?: number;
segments?: number;
rings?: number;
}
export class Sphere extends Mesh {
constructor(scene: Scene, options?: SphereOptions);
}
export interface CameraOptions extends NodeOptions {
fov?: number;
near?: number;
far?: number;
aspect?: number;
}
export class Camera extends Node { export class Camera extends Node {
fov: number; fov: number;
near: number; near: number;
@@ -130,30 +217,106 @@ declare module "@yawn/handles" {
aspect: number; aspect: number;
} }
export class ArcRotateCamera extends Camera { export class ArcRotateCamera extends Camera {
constructor(scene: Scene, options?: CameraOptions & { target?: Node; alpha?: number; beta?: number; radius?: number; controls?: { element: HTMLElement; pointer?: boolean; controller?: boolean } }); constructor(
scene: Scene,
options?: CameraOptions & {
target?: Node;
alpha?: number;
beta?: number;
radius?: number;
controls?: {
element: HTMLElement;
pointer?: boolean;
controller?: boolean;
};
},
);
alpha: number; alpha: number;
beta: number; beta: number;
radius: number; radius: number;
detachControls(): this;
} }
export class FreeCamera extends Camera { export class FreeCamera extends Camera {
constructor(scene: Scene, options?: CameraOptions & { controls?: { element: HTMLElement; keyboard?: boolean; pointer?: boolean; controller?: boolean; speed?: number } }); constructor(
scene: Scene,
options?: CameraOptions & {
controls?: {
element: HTMLElement;
keyboard?: boolean;
pointer?: boolean;
controller?: boolean;
speed?: number;
};
},
);
} }
export class FollowCamera extends Camera { export class FollowCamera extends Camera {
constructor(scene: Scene, options?: CameraOptions & { target?: Node; distance?: number; height?: number; smoothing?: number }); constructor(
scene: Scene,
options?: CameraOptions & {
target?: Node;
distance?: number;
height?: number;
smoothing?: number;
},
);
} }
export interface LightOptions extends NodeOptions { color?: ArrayLike<number>; intensity?: number } export interface LightOptions extends NodeOptions {
export class PointLight extends Node { constructor(scene: Scene, options?: LightOptions & { range?: number }); intensity: number; range: number } color?: ArrayLike<number>;
export class AmbientLight extends Node { constructor(scene: Scene, options?: LightOptions); intensity: number } intensity?: number;
export class DirectionalLight extends Node { constructor(scene: Scene, options?: LightOptions); intensity: number } }
export class SpotLight extends Node { constructor(scene: Scene, options?: LightOptions & { range?: number; innerAngle?: number; outerAngle?: number }) } export class PointLight extends Node {
export class RectAreaLight extends Node { constructor(scene: Scene, options?: LightOptions & { width?: number; height?: number }) } constructor(scene: Scene, options?: LightOptions & { range?: number });
intensity: number;
range: number;
}
export class AmbientLight extends Node {
constructor(scene: Scene, options?: LightOptions);
intensity: number;
}
export class DirectionalLight extends Node {
constructor(scene: Scene, options?: LightOptions);
intensity: number;
}
export class SpotLight extends Node {
constructor(
scene: Scene,
options?: LightOptions & {
range?: number;
innerAngle?: number;
outerAngle?: number;
},
);
}
export class RectAreaLight extends Node {
constructor(
scene: Scene,
options?: LightOptions & { width?: number; height?: number },
);
}
export class ComputePass { constructor(options: Record<string, unknown>); readonly id: string } export class ComputePass {
export class FXAA { constructor(scene: Scene, options?: Record<string, unknown>); readonly ready: Promise<this>; dispose(): Promise<void> } constructor(options: Record<string, unknown>);
export class ColorGrading extends FXAA { constructor(scene: Scene, options?: { toneMap?: "aces" | "reinhard" | "linear"; amount?: number }) } readonly id: string;
export class DynamicExposure extends FXAA { constructor(scene: Scene, options?: { exposure?: number }) } }
export class SSAO extends FXAA { constructor(scene: Scene, options?: { amount?: number }) } export class FXAA {
constructor(scene: Scene, options?: Record<string, unknown>);
readonly ready: Promise<this>;
dispose(): Promise<void>;
}
export class ColorGrading extends FXAA {
constructor(
scene: Scene,
options?: { toneMap?: "aces" | "reinhard" | "linear"; amount?: number },
);
}
export class DynamicExposure extends FXAA {
constructor(scene: Scene, options?: { exposure?: number });
}
export class SSAO extends FXAA {
constructor(scene: Scene, options?: { amount?: number });
}
export function importGltf(scene: Scene, url: string | URL): Promise<Mesh[]>; export function importGltf(scene: Scene, url: string | URL): Promise<Mesh[]>;
} }
+238 -68
View File
@@ -4,26 +4,43 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<meta name="description" content="Learn Yawn from your first WebGPU scene through shared memory and custom render graphs." /> <meta
name="description"
content="Learn Yawn from your first WebGPU scene through shared memory and custom render graphs."
/>
<title>Learn Yawn — Documentation</title> <title>Learn Yawn — Documentation</title>
<script src="/assets/theme.js"></script>
<link rel="stylesheet" href="/assets/site.css" /> <link rel="stylesheet" href="/assets/site.css" />
<link rel="stylesheet" href="/assets/docs.css" /> <link rel="stylesheet" href="/assets/docs.css" />
<link rel="stylesheet" href="/assets/theme.css" />
<script type="module" src="/assets/docs.js"></script> <script type="module" src="/assets/docs.js"></script>
</head> </head>
<body class="docs-body"> <body class="docs-body">
<header class="site-header docs-header"> <header class="site-header docs-header">
<a class="wordmark" href="/" aria-label="Yawn home"> <a class="wordmark" href="/" aria-label="Yawn home">
<span class="wordmark-mark" aria-hidden="true"><i></i><i></i><i></i></span><span>Yawn</span> <span class="wordmark-mark" aria-hidden="true"
><i></i><i></i><i></i></span
><span>Yawn</span>
</a> </a>
<span class="docs-product">Docs</span> <span class="docs-product">Docs</span>
<label class="docs-search"> <label class="docs-search">
<span aria-hidden="true">⌕</span> <span aria-hidden="true">⌕</span>
<input type="search" placeholder="Filter this guide" aria-label="Filter documentation sections" /> <input
type="search"
placeholder="Filter this guide"
aria-label="Filter documentation sections"
/>
<kbd>/</kbd> <kbd>/</kbd>
</label> </label>
<nav class="site-nav" aria-label="Documentation navigation"> <nav class="site-nav" aria-label="Documentation navigation">
<a href="/playground">Playground</a><a href="https://git.heaust.org/heaust/yawn">Source</a> <a href="/playground">Playground</a
><a href="https://git.heaust.org/heaust/yawn">Source</a>
</nav> </nav>
<select class="theme-select" data-theme-select aria-label="Color theme">
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</header> </header>
<div class="docs-layout"> <div class="docs-layout">
@@ -54,7 +71,12 @@
<a href="#custom-rows">Craft your own API</a> <a href="#custom-rows">Craft your own API</a>
<a href="#deployment">Deployment</a> <a href="#deployment">Deployment</a>
</div> </div>
<a class="sidebar-playground" href="/playground"><span>▶</span><div><strong>Try as you learn</strong><small>Open the playground</small></div></a> <a class="sidebar-playground" href="/playground"
><span>▶</span>
<div>
<strong>Try as you learn</strong><small>Open the playground</small>
</div></a
>
</aside> </aside>
<main class="docs-main"> <main class="docs-main">
@@ -63,13 +85,20 @@
<div class="doc-eyebrow">Start here · 5 minute read</div> <div class="doc-eyebrow">Start here · 5 minute read</div>
<h1>Build your first Yawn scene</h1> <h1>Build your first Yawn scene</h1>
<p class="doc-lede"> <p class="doc-lede">
This guide starts from an empty HTML file and ends with a WebGPU triangle you can This guide starts from an empty HTML file and ends with a WebGPU
move, shade, and inspect. You do not need engine experience. Every new term is triangle you can move, shade, and inspect. You do not need engine
explained where it first appears. experience. Every new term is explained where it first appears.
</p> </p>
<div class="callout callout-blue"> <div class="callout callout-blue">
<span class="callout-icon">i</span> <span class="callout-icon">i</span>
<div><strong>New to programming?</strong><p>Type the examples exactly as shown. Words in <code>code style</code> are names the computer expects; the prose around them explains why.</p></div> <div>
<strong>New to programming?</strong>
<p>
Type the examples exactly as shown. Words in
<code>code style</code> are names the computer expects; the
prose around them explains why.
</p>
</div>
</div> </div>
</section> </section>
@@ -77,34 +106,55 @@
<div class="section-kicker"><span>01</span> Installation</div> <div class="section-kicker"><span>01</span> Installation</div>
<h2>Load Handles from Yawn’s CDN</h2> <h2>Load Handles from Yawn’s CDN</h2>
<p> <p>
You do not build or host Yawn yourself. An <em>import map</em> gives the CDN module a You do not build or host Yawn yourself. An
short name that the rest of this tutorial can use. Put this inside your page’s <em>import map</em> gives the CDN module a short name that the
rest of this tutorial can use. Put this inside your page’s
<code>&lt;head&gt;</code>: <code>&lt;head&gt;</code>:
</p> </p>
<div class="code-block" data-language="html"> <div class="code-block" data-language="html">
<div class="code-title"><span>index.html</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<span>index.html</span
><button type="button" data-copy>Copy</button>
</div>
<pre><code>&lt;script type="importmap"&gt; <pre><code>&lt;script type="importmap"&gt;
{ {
"imports": { "imports": {
"@yawn/core": "https://yawn.heaust.org/pkg/core.js",
"@yawn/handles": "https://yawn.heaust.org/pkg/handles.js" "@yawn/handles": "https://yawn.heaust.org/pkg/handles.js"
} }
} }
&lt;/script&gt;</code></pre> &lt;/script&gt;</code></pre>
</div> </div>
<p> <p>
That URL serves a ready-to-use ES module. Its render worker, import worker, picking Core and Handles stay separate: Handles uses the mapped Core
worker, and WebAssembly module all continue loading from Yawn’s CDN automatically. module instead of containing its own copy. Their render worker,
import worker, picking worker, and WebAssembly module all continue
loading from Yawn’s CDN automatically.
</p> </p>
<h3>Make a canvas</h3> <h3>Make a canvas</h3>
<p>A <code>canvas</code> is the rectangle where WebGPU will draw. Add one to the page body:</p> <p>
A <code>canvas</code> is the rectangle where WebGPU will draw. Add
one to the page body:
</p>
<div class="code-block" data-language="html"> <div class="code-block" data-language="html">
<div class="code-title"><span>index.html</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<span>index.html</span
><button type="button" data-copy>Copy</button>
</div>
<pre><code>&lt;canvas id="view" width="1280" height="720"&gt;&lt;/canvas&gt; <pre><code>&lt;canvas id="view" width="1280" height="720"&gt;&lt;/canvas&gt;
&lt;script type="module" src="/app.js"&gt;&lt;/script&gt;</code></pre> &lt;script type="module" src="/app.js"&gt;&lt;/script&gt;</code></pre>
</div> </div>
<div class="callout"> <div class="callout">
<span class="callout-icon">!</span> <span class="callout-icon">!</span>
<div><strong>Your page needs isolation headers</strong><p>Your own web host must send <code>COOP: same-origin</code> and <code>COEP: require-corp</code> so SharedArrayBuffer is available. The CDN already sends the matching CORS and resource-policy headers for Yawn’s files.</p></div> <div>
<strong>Your page needs isolation headers</strong>
<p>
Your own web host must send <code>COOP: same-origin</code> and
<code>COEP: require-corp</code> so SharedArrayBuffer is
available. The CDN already sends the matching CORS and
resource-policy headers for Yawn’s files.
</p>
</div>
</div> </div>
</section> </section>
@@ -113,12 +163,16 @@
<h2>Scene → material → mesh</h2> <h2>Scene → material → mesh</h2>
<p> <p>
A <strong>scene</strong> owns the shared data and render graph. A A <strong>scene</strong> owns the shared data and render graph. A
<strong>material</strong> describes the surface. A <strong>mesh</strong> supplies points <strong>material</strong> describes the surface. A
and tells Yawn which order to connect them. <strong>mesh</strong> supplies points and tells Yawn which order
to connect them.
</p> </p>
<div class="code-block code-block-large" data-language="typescript"> <div class="code-block code-block-large" data-language="typescript">
<div class="code-title"><span>app.js</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<pre><code>import { Mesh, PBRMaterial, Scene } from "@yawn/handles"; <span>app.js</span><button type="button" data-copy>Copy</button>
</div>
<pre><code>import "@yawn/core";
import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
const canvas = document.querySelector("#view"); const canvas = document.querySelector("#view");
const scene = new Scene(canvas, { hdr: true, fps: 60 }); const scene = new Scene(canvas, { hdr: true, fps: 60 });
@@ -141,23 +195,48 @@ const triangle = new Mesh(scene, {
await triangle.ready;</code></pre> await triangle.ready;</code></pre>
</div> </div>
<div class="explain-grid"> <div class="explain-grid">
<div><code>new</code><p>Creates one object. Here that is a scene, material, or mesh handle.</p></div> <div>
<div><code>await …ready</code><p>Waits for setup to finish before the next object depends on it.</p></div> <code>new</code>
<div><code>[x, y, z]</code><p>One point in 3D space: horizontal, vertical, and depth.</p></div> <p>
Creates one object. Here that is a scene, material, or mesh
handle.
</p>
</div>
<div>
<code>await …ready</code>
<p>
Waits for setup to finish before the next object depends on
it.
</p>
</div>
<div>
<code>[x, y, z]</code>
<p>One point in 3D space: horizontal, vertical, and depth.</p>
</div>
</div> </div>
<a class="inline-play" href="/playground"><span>▶</span><div><strong>Run this example</strong><small>It is already loaded in the playground</small></div><b>Open →</b></a> <a class="inline-play" href="/playground"
><span>▶</span>
<div>
<strong>Run this example</strong
><small>It is already loaded in the playground</small>
</div>
<b>Open →</b></a
>
</section> </section>
<section id="camera" data-title="Add a camera"> <section id="camera" data-title="Add a camera">
<div class="section-kicker"><span>03</span> Move around</div> <div class="section-kicker"><span>03</span> Move around</div>
<h2>Add a camera you can orbit</h2> <h2>Add a camera you can orbit</h2>
<p> <p>
The starter triangle is already in clip space, so it is visible without a camera. The starter triangle is already in clip space, so it is visible
For a 3D world, add an <code>ArcRotateCamera</code>. Drag to orbit and use the wheel to without a camera. For a 3D world, add an
<code>ArcRotateCamera</code>. Drag to orbit and use the wheel to
zoom. zoom.
</p> </p>
<div class="code-block" data-language="typescript"> <div class="code-block" data-language="typescript">
<div class="code-title"><span>app.ts</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<span>app.ts</span><button type="button" data-copy>Copy</button>
</div>
<pre><code>import { ArcRotateCamera } from "@yawn/handles"; <pre><code>import { ArcRotateCamera } from "@yawn/handles";
const camera = new ArcRotateCamera(scene, { const camera = new ArcRotateCamera(scene, {
@@ -175,12 +254,16 @@ await camera.ready;</code></pre>
<div class="section-kicker"><span>04</span> Build a world</div> <div class="section-kicker"><span>04</span> Build a world</div>
<h2>Clone geometry, not work</h2> <h2>Clone geometry, not work</h2>
<p> <p>
Every <code>Mesh</code> is an instance. Calling <code>clone()</code> shares its geometry Every <code>Mesh</code> is an instance. Calling
and creates only a new transform and mesh slot. If one clone later changes its vertex <code>clone()</code> shares its geometry and creates only a new
transform and mesh slot. If one clone later changes its vertex
data, Yawn makes that geometry unique automatically. data, Yawn makes that geometry unique automatically.
</p> </p>
<div class="code-block" data-language="typescript"> <div class="code-block" data-language="typescript">
<div class="code-title"><span>instances.ts</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<span>instances.ts</span
><button type="button" data-copy>Copy</button>
</div>
<pre><code>for (let x = -4; x &lt;= 4; x++) { <pre><code>for (let x = -4; x &lt;= 4; x++) {
const copy = triangle.clone({ position: [x * 0.35, 0, 0] }); const copy = triangle.clone({ position: [x * 0.35, 0, 0] });
await copy.ready; await copy.ready;
@@ -191,11 +274,14 @@ await camera.ready;</code></pre>
<section id="materials" data-title="Materials and lights"> <section id="materials" data-title="Materials and lights">
<h2>Materials and lights are ordinary handles</h2> <h2>Materials and lights are ordinary handles</h2>
<p> <p>
Change a material after it is ready and Yawn writes the new value directly into its Change a material after it is ready and Yawn writes the new value
shared row. Lights use the same pattern. directly into its shared row. Lights use the same pattern.
</p> </p>
<div class="code-block" data-language="typescript"> <div class="code-block" data-language="typescript">
<div class="code-title"><span>lighting.ts</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<span>lighting.ts</span
><button type="button" data-copy>Copy</button>
</div>
<pre><code>import { AmbientLight, PointLight } from "@yawn/handles"; <pre><code>import { AmbientLight, PointLight } from "@yawn/handles";
const key = new PointLight(scene, { const key = new PointLight(scene, {
@@ -217,11 +303,15 @@ sky.roughness = 0.5; // one shared-memory write</code></pre>
<section id="models" data-title="Import a glTF model"> <section id="models" data-title="Import a glTF model">
<h2>Bring in a glTF model</h2> <h2>Bring in a glTF model</h2>
<p> <p>
<code>importGltf</code> fetches and parses <code>.gltf</code> or <code>.glb</code> data in <code>importGltf</code> fetches and parses <code>.gltf</code> or
a worker, then creates ordinary Yawn meshes and materials. <code>.glb</code> data in a worker, then creates ordinary Yawn
meshes and materials.
</p> </p>
<div class="code-block" data-language="typescript"> <div class="code-block" data-language="typescript">
<div class="code-title"><span>model.ts</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<span>model.ts</span
><button type="button" data-copy>Copy</button>
</div>
<pre><code>import { importGltf } from "@yawn/handles"; <pre><code>import { importGltf } from "@yawn/handles";
const meshes = await importGltf(scene, "/models/robot.glb"); const meshes = await importGltf(scene, "/models/robot.glb");
@@ -231,9 +321,15 @@ meshes[0].position.y = 0.5;</code></pre>
<section id="post-processing" data-title="Post processing"> <section id="post-processing" data-title="Post processing">
<h2>Add effects without leaving the scene API</h2> <h2>Add effects without leaving the scene API</h2>
<p>Effect handles add passes to the same render graph. Batch related additions to rebuild that graph once.</p> <p>
Effect handles add passes to the same render graph. Batch related
additions to rebuild that graph once.
</p>
<div class="code-block" data-language="typescript"> <div class="code-block" data-language="typescript">
<div class="code-title"><span>effects.ts</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<span>effects.ts</span
><button type="button" data-copy>Copy</button>
</div>
<pre><code>import { ColorGrading, FXAA } from "@yawn/handles"; <pre><code>import { ColorGrading, FXAA } from "@yawn/handles";
await scene.batchGraphUpdates(async () =&gt; { await scene.batchGraphUpdates(async () =&gt; {
@@ -248,27 +344,40 @@ await scene.batchGraphUpdates(async () =&gt; {
<div class="section-kicker"><span>05</span> Understand Yawn</div> <div class="section-kicker"><span>05</span> Understand Yawn</div>
<h2>Setup is messages. Motion is memory.</h2> <h2>Setup is messages. Motion is memory.</h2>
<p> <p>
Expensive structural changes—creating rows, allocating IDs, or replacing a render Expensive structural changes—creating rows, allocating IDs, or
graph—go to the render worker as messages. Values that already exist—positions, replacing a render graph—go to the render worker as messages.
colors, camera matrices, light strengths—change in shared memory. Values that already exist—positions, colors, camera matrices,
light strengths—change in shared memory.
</p> </p>
<div class="mental-model"> <div class="mental-model">
<div><strong>Infrequent control</strong><span>create · allocate · compile · switch</span></div> <div>
<strong>Infrequent control</strong
><span>create · allocate · compile · switch</span>
</div>
<i>→</i> <i>→</i>
<div class="mental-shared"><strong>Shared rows</strong><span>transform · shade · animate</span></div> <div class="mental-shared">
<strong>Shared rows</strong
><span>transform · shade · animate</span>
</div>
<i>→</i> <i>→</i>
<div><strong>Rust/WASM core</strong><span>schedule · upload · render</span></div> <div>
<strong>Rust/WASM core</strong
><span>schedule · upload · render</span>
</div>
</div> </div>
</section> </section>
<section id="shared-memory" data-title="Shared memory"> <section id="shared-memory" data-title="Shared memory">
<h2>Write your own hot data</h2> <h2>Write your own hot data</h2>
<p> <p>
Handles are views over named rows. Your application can add rows too. Each row is Handles are views over named rows. Your application can add rows
aligned for predictable CPU and GPU use. too. Each row is aligned for predictable CPU and GPU use.
</p> </p>
<div class="code-block" data-language="typescript"> <div class="code-block" data-language="typescript">
<div class="code-title"><span>simulation.ts</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<span>simulation.ts</span
><button type="button" data-copy>Copy</button>
</div>
<pre><code>const velocity = await scene.ensureRows( <pre><code>const velocity = await scene.ensureRows(
"app.velocity", 10_000, 16, "f32" "app.velocity", 10_000, 16, "f32"
); );
@@ -276,31 +385,48 @@ await scene.batchGraphUpdates(async () =&gt; {
velocity.row(42).set([1, 0, 0, 0]);</code></pre> velocity.row(42).set([1, 0, 0, 0]);</code></pre>
</div> </div>
<p> <p>
That final line changes four floats without cloning an object or posting a worker That final line changes four floats without cloning an object or
message. The scene marks shared data dirty so the next frame sees it. posting a worker message. The scene marks shared data dirty so the
next frame sees it.
</p> </p>
</section> </section>
<section id="profiler" data-title="GPU profiler"> <section id="profiler" data-title="GPU profiler">
<h2>Measure the passes the GPU actually ran</h2> <h2>Measure the passes the GPU actually ran</h2>
<p> <p>
Open <strong>Profile</strong> in the playground to enable timestamp queries. Yawn Open <strong>Profile</strong> in the playground to enable
reports the physical pass names and GPU milliseconds without serializing the render timestamp queries. Yawn reports the physical pass names and GPU
queue. Support depends on the browser and adapter. milliseconds without serializing the render queue. Support depends
on the browser and adapter.
</p> </p>
<div class="callout callout-blue"><span class="callout-icon">⌁</span><div><strong>Profile a real scene</strong><p>The playground’s profiler uses <code>core.onProfile()</code> and <code>core.setProfiler(true)</code>—the same public APIs available to your app.</p></div></div> <div class="callout callout-blue">
<span class="callout-icon">⌁</span>
<div>
<strong>Profile a real scene</strong>
<p>
The playground’s profiler uses
<code>core.onProfile()</code> and
<code>core.setProfiler(true)</code>—the same public APIs
available to your app.
</p>
</div>
</div>
</section> </section>
<section id="core-deep-dive" data-title="Using core directly"> <section id="core-deep-dive" data-title="Using core directly">
<div class="section-kicker"><span>06</span> Deep dive</div> <div class="section-kicker"><span>06</span> Deep dive</div>
<h2>Outgrow handles without outgrowing Yawn</h2> <h2>Outgrow handles without outgrowing Yawn</h2>
<p> <p>
<code>@yawn/core</code> has no scene, mesh, material, camera, or built-in shader. It <code>@yawn/core</code> has no scene, mesh, material, camera, or
owns a shared arena and a render-graph runtime. Handles are one replaceable frontend built-in shader. It owns a shared arena and a render-graph
that builds on those two primitives. runtime. Handles are one replaceable frontend that builds on those
two primitives.
</p> </p>
<div class="code-block" data-language="typescript"> <div class="code-block" data-language="typescript">
<div class="code-title"><span>core.ts</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<span>core.ts</span
><button type="button" data-copy>Copy</button>
</div>
<pre><code>import { YawnCore } from "https://yawn.heaust.org/pkg/core.js"; <pre><code>import { YawnCore } from "https://yawn.heaust.org/pkg/core.js";
const core = new YawnCore(canvas, { const core = new YawnCore(canvas, {
@@ -316,20 +442,25 @@ const particles = await core.createRows({
});</code></pre> });</code></pre>
</div> </div>
<p> <p>
From here, your frontend supplies WGSL, resources, pipelines, and a pass DAG. Core From here, your frontend supplies WGSL, resources, pipelines, and
compiles that description into an up-front loadout, aliases compatible transient a pass DAG. Core compiles that description into an up-front
textures, and records render work. loadout, aliases compatible transient textures, and records render
work.
</p> </p>
</section> </section>
<section id="custom-rows" data-title="Craft your own API"> <section id="custom-rows" data-title="Craft your own API">
<h2>Craft an API for your problem</h2> <h2>Craft an API for your problem</h2>
<p> <p>
A custom handle can be as small as an ID plus getters and setters into shared rows. A custom handle can be as small as an ID plus getters and setters
Keep domain policy in your code and send only structural changes to core. into shared rows. Keep domain policy in your code and send only
structural changes to core.
</p> </p>
<div class="code-block" data-language="typescript"> <div class="code-block" data-language="typescript">
<div class="code-title"><span>Particle.ts</span><button type="button" data-copy>Copy</button></div> <div class="code-title">
<span>Particle.ts</span
><button type="button" data-copy>Copy</button>
</div>
<pre><code>class Particle { <pre><code>class Particle {
constructor( constructor(
readonly id: number, readonly id: number,
@@ -346,11 +477,50 @@ const particles = await core.createRows({
<section id="deployment" data-title="Deployment"> <section id="deployment" data-title="Deployment">
<h2>Deployment checklist</h2> <h2>Deployment checklist</h2>
<ul class="deploy-list"> <ul class="deploy-list">
<li><span>1</span><div><strong>Serve over HTTPS</strong><p>WebGPU and cross-origin isolation require a secure browser context outside local development.</p></div></li> <li>
<li><span>2</span><div><strong>Keep the isolation headers</strong><p>Send <code>Cross-Origin-Opener-Policy: same-origin</code> and <code>Cross-Origin-Embedder-Policy: require-corp</code>.</p></div></li> <span>1</span>
<li><span>3</span><div><strong>Allow Yawn’s CDN</strong><p>The imported module keeps every worker and WASM request on <code>yawn.heaust.org</code>. If you use a Content Security Policy, allow that origin and <code>blob:</code> workers.</p></div></li> <div>
<strong>Serve over HTTPS</strong>
<p>
WebGPU and cross-origin isolation require a secure browser
context outside local development.
</p>
</div>
</li>
<li>
<span>2</span>
<div>
<strong>Keep the isolation headers</strong>
<p>
Send
<code>Cross-Origin-Opener-Policy: same-origin</code> and
<code>Cross-Origin-Embedder-Policy: require-corp</code>.
</p>
</div>
</li>
<li>
<span>3</span>
<div>
<strong>Allow Yawn’s CDN</strong>
<p>
The imported module keeps every worker and WASM request on
<code>yawn.heaust.org</code>. If you use a Content Security
Policy, allow that origin and <code>blob:</code> workers.
</p>
</div>
</li>
</ul> </ul>
<div class="next-card"><div><small>Next step</small><h3>Make the starter scene yours.</h3><p>Open the playground, choose a snippet, and save a revision you can share.</p></div><a class="button" href="/playground">Open playground →</a></div> <div class="next-card">
<div>
<small>Next step</small>
<h3>Make the starter scene yours.</h3>
<p>
Open the playground, choose a snippet, and save a revision you
can share.
</p>
</div>
<a class="button" href="/playground">Open playground →</a>
</div>
</section> </section>
</article> </article>
</main> </main>
+104 -37
View File
@@ -9,13 +9,17 @@
content="Yawn is a WebGPU engine that starts with friendly scene handles and scales down to a programmable Rust/WASM render core." content="Yawn is a WebGPU engine that starts with friendly scene handles and scales down to a programmable Rust/WASM render core."
/> />
<title>Yawn — a WebGPU engine that grows with you</title> <title>Yawn — a WebGPU engine that grows with you</title>
<script src="/assets/theme.js"></script>
<link rel="stylesheet" href="/assets/site.css" /> <link rel="stylesheet" href="/assets/site.css" />
<link rel="stylesheet" href="/assets/theme.css" />
<script type="module" src="/assets/site.js"></script> <script type="module" src="/assets/site.js"></script>
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header">
<a class="wordmark" href="/" aria-label="Yawn home"> <a class="wordmark" href="/" aria-label="Yawn home">
<span class="wordmark-mark" aria-hidden="true"><i></i><i></i><i></i></span> <span class="wordmark-mark" aria-hidden="true"
><i></i><i></i><i></i
></span>
<span>Yawn</span> <span>Yawn</span>
</a> </a>
<nav class="site-nav" aria-label="Main navigation"> <nav class="site-nav" aria-label="Main navigation">
@@ -23,6 +27,11 @@
<a href="/playground">Playground</a> <a href="/playground">Playground</a>
<a href="https://git.heaust.org/heaust/yawn">Source</a> <a href="https://git.heaust.org/heaust/yawn">Source</a>
</nav> </nav>
<select class="theme-select" data-theme-select aria-label="Color theme">
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<a class="button button-small" href="/docs#first-scene">Get started</a> <a class="button button-small" href="/docs#first-scene">Get started</a>
</header> </header>
@@ -32,17 +41,24 @@
<div class="eyebrow"><span></span> WebGPU without the ceremony</div> <div class="eyebrow"><span></span> WebGPU without the ceremony</div>
<h1>Start with a scene.<br />Scale into an engine.</h1> <h1>Start with a scene.<br />Scale into an engine.</h1>
<p class="hero-lede"> <p class="hero-lede">
Yawn gives you approachable TypeScript handles on day one, direct shared-memory Yawn gives you approachable TypeScript handles on day one, direct
performance when you need it, and a programmable render core when your ideas outgrow shared-memory performance when you need it, and a programmable
conventions. render core when your ideas outgrow conventions.
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<a class="button" href="/playground">Open the playground <span>→</span></a> <a class="button" href="/playground"
<a class="button button-quiet" href="/docs#installation">Read the 5-minute setup</a> >Open the playground <span>→</span></a
>
<a class="button button-quiet" href="/docs#installation"
>Read the 5-minute setup</a
>
</div> </div>
<div class="hero-note"> <div class="hero-note">
<span class="browser-dots" aria-hidden="true"><i></i><i></i><i></i></span> <span class="browser-dots" aria-hidden="true"
One CDN import · No framework required · Rust/WASM core ><i></i><i></i><i></i
></span>
Separate Core + Handles modules · No framework required · Rust/WASM
core
</div> </div>
</div> </div>
@@ -78,7 +94,9 @@
<section class="trust-strip" aria-label="Yawn principles"> <section class="trust-strip" aria-label="Yawn principles">
<div><strong>One</strong><span>shared arena</span></div> <div><strong>One</strong><span>shared arena</span></div>
<div><strong>Zero</strong><span>messages for hot state</span></div> <div><strong>Zero</strong><span>messages for hot state</span></div>
<div><strong>Any</strong><span>render graph you can describe</span></div> <div>
<strong>Any</strong><span>render graph you can describe</span>
</div>
<div><strong>Small</strong><span>Rust/WASM foundation</span></div> <div><strong>Small</strong><span>Rust/WASM foundation</span></div>
</section> </section>
@@ -87,8 +105,9 @@
<div class="eyebrow"><span></span> Progressive by design</div> <div class="eyebrow"><span></span> Progressive by design</div>
<h2>Use exactly as much engine as you need.</h2> <h2>Use exactly as much engine as you need.</h2>
<p> <p>
You do not have to choose between an easy API and a serious foundation. Yawn keeps You do not have to choose between an easy API and a serious
those layers separate, so learning more never means starting over. foundation. Yawn keeps those layers separate, so learning more never
means starting over.
</p> </p>
</div> </div>
<div class="gear-grid"> <div class="gear-grid">
@@ -97,8 +116,9 @@
<div class="gear-icon"><span class="cube-icon"></span></div> <div class="gear-icon"><span class="cube-icon"></span></div>
<h3>Build with handles</h3> <h3>Build with handles</h3>
<p> <p>
Start with familiar scenes, meshes, materials, lights, cameras, glTF imports, and Start with familiar scenes, meshes, materials, lights, cameras,
post effects. Constructors are explicit and every async boundary is visible. glTF imports, and post effects. Constructors are explicit and
every async boundary is visible.
</p> </p>
<a href="/docs#first-scene">Make your first scene <span>→</span></a> <a href="/docs#first-scene">Make your first scene <span>→</span></a>
</article> </article>
@@ -107,18 +127,22 @@
<div class="gear-icon"><span class="rows-icon"></span></div> <div class="gear-icon"><span class="rows-icon"></span></div>
<h3>Move through shared data</h3> <h3>Move through shared data</h3>
<p> <p>
Positions, cameras, lights, materials, and your own application rows live in one Positions, cameras, lights, materials, and your own application
SharedArrayBuffer. Hot updates become direct typed-array writes. rows live in one SharedArrayBuffer. Hot updates become direct
typed-array writes.
</p> </p>
<a href="/docs#shared-memory">Understand the fast path <span>→</span></a> <a href="/docs#shared-memory"
>Understand the fast path <span>→</span></a
>
</article> </article>
<article class="gear-card"> <article class="gear-card">
<div class="gear-number">03</div> <div class="gear-number">03</div>
<div class="gear-icon"><span class="graph-icon"></span></div> <div class="gear-icon"><span class="graph-icon"></span></div>
<h3>Author the whole graph</h3> <h3>Author the whole graph</h3>
<p> <p>
Use core directly when you need custom resources, WGSL pipelines, compute, pass Use core directly when you need custom resources, WGSL pipelines,
dependencies, transient aliasing, and up-front GPU loadouts. compute, pass dependencies, transient aliasing, and up-front GPU
loadouts.
</p> </p>
<a href="/docs#core-deep-dive">Go beneath handles <span>→</span></a> <a href="/docs#core-deep-dive">Go beneath handles <span>→</span></a>
</article> </article>
@@ -128,18 +152,30 @@
<section class="architecture-section"> <section class="architecture-section">
<div class="section-shell architecture-grid"> <div class="section-shell architecture-grid">
<div class="architecture-copy"> <div class="architecture-copy">
<div class="eyebrow eyebrow-light"><span></span> A quiet hot path</div> <div class="eyebrow eyebrow-light">
<span></span> A quiet hot path
</div>
<h2>Your frame should move data, not negotiate it.</h2> <h2>Your frame should move data, not negotiate it.</h2>
<p> <p>
Yawn pays setup costs when your scene or graph changes. During play, ordinary updates Yawn pays setup costs when your scene or graph changes. During
stay in shared rows that JavaScript and the render worker can both see. play, ordinary updates stay in shared rows that JavaScript and the
render worker can both see.
</p> </p>
<ul class="check-list"> <ul class="check-list">
<li><span>✓</span> Structure-of-arrays layout with aligned rows</li> <li>
<li><span>✓</span> Render graphs compiled before they become active</li> <span>✓</span> Structure-of-arrays layout with aligned rows
<li><span>✓</span> Optional timestamp profiling for physical GPU passes</li> </li>
<li>
<span>✓</span> Render graphs compiled before they become active
</li>
<li>
<span>✓</span> Optional timestamp profiling for physical GPU
passes
</li>
</ul> </ul>
<a class="text-link-light" href="/docs#mental-model">See how the pieces connect →</a> <a class="text-link-light" href="/docs#mental-model"
>See how the pieces connect →</a
>
</div> </div>
<div class="architecture-visual" aria-label="Yawn data flow"> <div class="architecture-visual" aria-label="Yawn data flow">
<div class="flow-label">Your application</div> <div class="flow-label">Your application</div>
@@ -148,11 +184,18 @@
</div> </div>
<div class="flow-line"><i></i><i></i><i></i></div> <div class="flow-line"><i></i><i></i><i></i></div>
<div class="arena-card"> <div class="arena-card">
<div><span></span><strong>SharedArrayBuffer</strong><small>aligned SOA arena</small></div> <div>
<div class="arena-rows"><i></i><i></i><i></i><i></i><i></i><i></i></div> <span></span><strong>SharedArrayBuffer</strong
><small>aligned SOA arena</small>
</div>
<div class="arena-rows">
<i></i><i></i><i></i><i></i><i></i><i></i>
</div>
</div> </div>
<div class="flow-line flow-line-bottom"><i></i></div> <div class="flow-line flow-line-bottom"><i></i></div>
<div class="core-card"><span>Rust / WASM</span><strong>Render graph core</strong></div> <div class="core-card">
<span>Rust / WASM</span><strong>Render graph core</strong>
</div>
<div class="gpu-line"></div> <div class="gpu-line"></div>
<div class="gpu-card">WebGPU</div> <div class="gpu-card">WebGPU</div>
</div> </div>
@@ -167,17 +210,30 @@
<ol class="steps"> <ol class="steps">
<li> <li>
<span class="step-index">1</span> <span class="step-index">1</span>
<div><h3>Create a scene</h3><p>Give Yawn a canvas and await one clear readiness promise.</p></div> <div>
<h3>Create a scene</h3>
<p>Give Yawn a canvas and await one clear readiness promise.</p>
</div>
<code>new Scene(canvas)</code> <code>new Scene(canvas)</code>
</li> </li>
<li> <li>
<span class="step-index">2</span> <span class="step-index">2</span>
<div><h3>Add what you can see</h3><p>Materials and meshes own typed handles, not hidden global state.</p></div> <div>
<h3>Add what you can see</h3>
<p>
Materials and meshes own typed handles, not hidden global state.
</p>
</div>
<code>new Mesh(scene, options)</code> <code>new Mesh(scene, options)</code>
</li> </li>
<li> <li>
<span class="step-index">3</span> <span class="step-index">3</span>
<div><h3>Change values directly</h3><p>After setup, ordinary property updates write into shared rows.</p></div> <div>
<h3>Change values directly</h3>
<p>
After setup, ordinary property updates write into shared rows.
</p>
</div>
<code>mesh.position.x += 0.1</code> <code>mesh.position.x += 0.1</code>
</li> </li>
</ol> </ol>
@@ -185,21 +241,32 @@
<section class="cta-section section-shell"> <section class="cta-section section-shell">
<div> <div>
<div class="eyebrow eyebrow-light"><span></span> The best way to understand it</div> <div class="eyebrow eyebrow-light">
<span></span> The best way to understand it
</div>
<h2>Change the code. Watch the passes.</h2> <h2>Change the code. Watch the passes.</h2>
<p> <p>
The playground includes TypeScript intelligence, reusable snippets, live output, The playground includes TypeScript intelligence, reusable snippets,
shareable SQLite-backed revisions, and the engine’s real GPU profiler. live output, shareable SQLite-backed revisions, and the engine’s
real GPU profiler.
</p> </p>
</div> </div>
<a class="button button-white" href="/playground">Launch playground <span>↗</span></a> <a class="button button-white" href="/playground"
>Launch playground <span>↗</span></a
>
</section> </section>
</main> </main>
<footer class="site-footer section-shell"> <footer class="site-footer section-shell">
<a class="wordmark" href="/"><span class="wordmark-mark"><i></i><i></i><i></i></span><span>Yawn</span></a> <a class="wordmark" href="/"
><span class="wordmark-mark"><i></i><i></i><i></i></span
><span>Yawn</span></a
>
<p>Start obvious. Optimize deliberately.</p> <p>Start obvious. Optimize deliberately.</p>
<nav aria-label="Footer navigation"><a href="/docs">Docs</a><a href="/playground">Playground</a><a href="https://git.heaust.org/heaust/yawn">Source</a></nav> <nav aria-label="Footer navigation">
<a href="/docs">Docs</a><a href="/playground">Playground</a
><a href="https://git.heaust.org/heaust/yawn">Source</a>
</nav>
</footer> </footer>
</body> </body>
</html> </html>
+178 -28
View File
@@ -4,9 +4,22 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f7fbff" /> <meta name="theme-color" content="#f7fbff" />
<meta name="description" content="Write, run, profile, and share Yawn WebGPU scenes." /> <meta
name="description"
content="Write, run, profile, and share Yawn WebGPU scenes."
/>
<title>Yawn Playground</title> <title>Yawn Playground</title>
<script src="/assets/theme.js"></script>
<link rel="stylesheet" href="/assets/playground.css" /> <link rel="stylesheet" href="/assets/playground.css" />
<link rel="stylesheet" href="/assets/theme.css" />
<script type="importmap">
{
"imports": {
"@yawn/core": "/pkg/core.js",
"@yawn/handles": "/pkg/handles.js"
}
}
</script>
<script src="/assets/monaco/vs/loader.js"></script> <script src="/assets/monaco/vs/loader.js"></script>
<script type="module" src="/assets/playground.js"></script> <script type="module" src="/assets/playground.js"></script>
</head> </head>
@@ -14,11 +27,18 @@
<main class="playground-app"> <main class="playground-app">
<header class="playground-header"> <header class="playground-header">
<a class="playground-brand" href="/" aria-label="Yawn home"> <a class="playground-brand" href="/" aria-label="Yawn home">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><strong>Yawn</strong> <span class="brand-mark" aria-hidden="true"
><i></i><i></i><i></i></span
><strong>Yawn</strong>
</a> </a>
<div class="header-divider"></div> <div class="header-divider"></div>
<div class="project-name"> <div class="project-name">
<input id="project-title" maxlength="80" value="Sky triangle" aria-label="Playground title" /> <input
id="project-title"
maxlength="80"
value="Three-light icosphere"
aria-label="Playground title"
/>
<span id="dirty-indicator" title="Unsaved changes"></span> <span id="dirty-indicator" title="Unsaved changes"></span>
<span id="revision-label">Draft</span> <span id="revision-label">Draft</span>
</div> </div>
@@ -26,6 +46,11 @@
<a href="/docs">Docs</a> <a href="/docs">Docs</a>
<a href="https://git.heaust.org/heaust/yawn">Source</a> <a href="https://git.heaust.org/heaust/yawn">Source</a>
</nav> </nav>
<select class="theme-select" data-theme-select aria-label="Color theme">
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<button id="share-button" class="tool-button" type="button" hidden> <button id="share-button" class="tool-button" type="button" hidden>
<span aria-hidden="true">↗</span> Share <span aria-hidden="true">↗</span> Share
</button> </button>
@@ -39,9 +64,15 @@
<div class="playground-tools"> <div class="playground-tools">
<div class="tool-group"> <div class="tool-group">
<button id="snippets-button" type="button"><span class="braces">{ }</span> Snippets</button> <button id="snippets-button" type="button">
<button id="reset-button" type="button"><span aria-hidden="true">↶</span> Reset</button> <span class="braces">{ }</span> Snippets
<button id="format-button" type="button"><span aria-hidden="true">≡</span> Format</button> </button>
<button id="reset-button" type="button">
<span aria-hidden="true">↶</span> Reset
</button>
<button id="format-button" type="button">
<span aria-hidden="true">≡</span> Format
</button>
</div> </div>
<div class="tool-context"> <div class="tool-context">
<span class="language-dot"></span> <span class="language-dot"></span>
@@ -50,17 +81,29 @@
<span id="type-status">Starting language service…</span> <span id="type-status">Starting language service…</span>
</div> </div>
<div class="tool-group tool-group-right"> <div class="tool-group tool-group-right">
<button id="inspector-button" type="button" aria-pressed="true"><span aria-hidden="true">▤</span> Inspector</button> <button id="inspector-button" type="button" aria-pressed="true">
<button id="fullscreen-button" type="button" aria-label="Toggle fullscreen"><span aria-hidden="true">⛶</span></button> <span aria-hidden="true">▤</span> Inspector
</button>
<button
id="fullscreen-button"
type="button"
aria-label="Toggle fullscreen"
>
<span aria-hidden="true">⛶</span>
</button>
</div> </div>
</div> </div>
<div class="workbench" id="workbench"> <div class="workbench" id="workbench">
<section class="editor-pane" aria-label="TypeScript editor"> <section class="editor-pane" aria-label="TypeScript editor">
<div class="pane-tabs"> <div class="pane-tabs">
<button class="pane-tab active" type="button"><span class="ts-icon">TS</span> scene.ts <i id="tab-dirty"></i></button> <button class="pane-tab active" type="button">
<span class="ts-icon">TS</span> scene.ts <i id="tab-dirty"></i>
</button>
</div>
<div id="editor" class="editor">
<div class="editor-loading">Loading TypeScript editor…</div>
</div> </div>
<div id="editor" class="editor"><div class="editor-loading">Loading TypeScript editor…</div></div>
<footer class="editor-status"> <footer class="editor-status">
<span id="cursor-status">Ln 1, Col 1</span> <span id="cursor-status">Ln 1, Col 1</span>
<span>Spaces: 2</span> <span>Spaces: 2</span>
@@ -70,7 +113,13 @@
</footer> </footer>
</section> </section>
<div class="resize-handle" id="resize-handle" role="separator" aria-orientation="vertical" aria-label="Resize editor and preview"></div> <div
class="resize-handle"
id="resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize editor and preview"
></div>
<section class="stage-pane" aria-label="Yawn output"> <section class="stage-pane" aria-label="Yawn output">
<div class="stage-toolbar"> <div class="stage-toolbar">
@@ -95,22 +144,73 @@
</div> </div>
<div class="inspector" id="inspector"> <div class="inspector" id="inspector">
<div class="inspector-tabs" role="tablist"> <div class="inspector-tabs" role="tablist">
<button type="button" class="active" role="tab" aria-selected="true" data-panel="console">Console <span id="console-count">0</span></button> <button
<button type="button" role="tab" aria-selected="false" data-panel="profile">GPU profiler</button> type="button"
<button id="clear-console" class="clear-console" type="button">Clear</button> class="active"
role="tab"
aria-selected="true"
data-panel="console"
>
Console <span id="console-count">0</span>
</button>
<button
type="button"
role="tab"
aria-selected="false"
data-panel="profile"
>
GPU profiler
</button>
<button id="clear-console" class="clear-console" type="button">
Clear
</button>
</div> </div>
<div class="console-panel inspector-panel active" id="console-panel" role="tabpanel"> <div
<div class="console-empty" id="console-empty"><span>›_</span> Output from <code>log()</code> appears here.</div> class="console-panel inspector-panel active"
id="console-panel"
role="tabpanel"
>
<div class="console-empty" id="console-empty">
<span>›_</span> Output from <code>log()</code> appears here.
</div>
<ol id="console-lines"></ol> <ol id="console-lines"></ol>
</div> </div>
<div class="profile-panel inspector-panel" id="profile-panel" role="tabpanel"> <div
class="profile-panel inspector-panel"
id="profile-panel"
role="tabpanel"
>
<div class="profile-empty" id="profile-empty"> <div class="profile-empty" id="profile-empty">
<span class="pulse-icon">⌁</span> <span class="pulse-icon">⌁</span>
<div><strong>Waiting for GPU timestamps</strong><small>Run the scene to inspect its physical passes.</small></div> <div>
<strong>Waiting for GPU timestamps</strong
><small>Run the scene to inspect its physical passes.</small>
</div>
</div> </div>
<div class="profile-results" id="profile-results" hidden> <div class="profile-results" id="profile-results" hidden>
<div class="profile-summary"><div><small>Total GPU time</small><strong id="profile-total">—</strong></div><div><small>Readback latency</small><strong id="profile-latency">—</strong></div><div><small>Adapter</small><strong id="profile-adapter">—</strong></div></div> <div class="profile-summary">
<table><thead><tr><th>Physical pass</th><th>GPU time</th></tr></thead><tbody id="profile-passes"></tbody></table> <div>
<small>Total GPU time</small
><strong id="profile-total">—</strong>
</div>
<div>
<small>Readback latency</small
><strong id="profile-latency">—</strong>
</div>
<div>
<small>Adapter</small
><strong id="profile-adapter">—</strong>
</div>
</div>
<table>
<thead>
<tr>
<th>Physical pass</th>
<th>GPU time</th>
</tr>
</thead>
<tbody id="profile-passes"></tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
@@ -120,16 +220,66 @@
<dialog id="snippets-dialog" class="snippets-dialog"> <dialog id="snippets-dialog" class="snippets-dialog">
<div class="dialog-heading"> <div class="dialog-heading">
<div><span>Code library</span><h2>Snippets</h2><p>Insert a focused building block at your cursor.</p></div> <div>
<button type="button" data-close-dialog aria-label="Close snippets">×</button> <span>Code library</span>
<h2>Snippets</h2>
<p>Insert a focused building block at your cursor.</p>
</div>
<button type="button" data-close-dialog aria-label="Close snippets">
×
</button>
</div>
<div class="snippet-search">
<span>⌕</span
><input
type="search"
placeholder="Find a snippet"
aria-label="Find a snippet"
/>
</div> </div>
<div class="snippet-search"><span>⌕</span><input type="search" placeholder="Find a snippet" aria-label="Find a snippet" /></div>
<div class="snippet-grid"> <div class="snippet-grid">
<button type="button" data-snippet="camera"><span class="snippet-icon">◎</span><div><strong>Orbit camera</strong><small>Pointer orbit and wheel zoom</small><code>ArcRotateCamera</code></div><b>+</b></button> <button type="button" data-snippet="camera">
<button type="button" data-snippet="light"><span class="snippet-icon">☀</span><div><strong>Point light</strong><small>Warm, local illumination</small><code>PointLight</code></div><b>+</b></button> <span class="snippet-icon">◎</span>
<button type="button" data-snippet="instances"><span class="snippet-icon">◇</span><div><strong>Mesh instances</strong><small>Clone shared geometry</small><code>mesh.clone()</code></div><b>+</b></button> <div>
<button type="button" data-snippet="rows"><span class="snippet-icon">≋</span><div><strong>Shared rows</strong><small>Add application-owned hot data</small><code>scene.ensureRows()</code></div><b>+</b></button> <strong>Orbit camera</strong
<button type="button" data-snippet="post"><span class="snippet-icon">✦</span><div><strong>Post processing</strong><small>Color grading and FXAA</small><code>batchGraphUpdates()</code></div><b>+</b></button> ><small>Pointer orbit and wheel zoom</small
><code>ArcRotateCamera</code>
</div>
<b>+</b>
</button>
<button type="button" data-snippet="light">
<span class="snippet-icon">☀</span>
<div>
<strong>Point light</strong><small>Warm, local illumination</small
><code>PointLight</code>
</div>
<b>+</b>
</button>
<button type="button" data-snippet="instances">
<span class="snippet-icon">◇</span>
<div>
<strong>Mesh instances</strong><small>Clone shared geometry</small
><code>mesh.clone()</code>
</div>
<b>+</b>
</button>
<button type="button" data-snippet="rows">
<span class="snippet-icon">≋</span>
<div>
<strong>Shared rows</strong
><small>Add application-owned hot data</small
><code>scene.ensureRows()</code>
</div>
<b>+</b>
</button>
<button type="button" data-snippet="post">
<span class="snippet-icon">✦</span>
<div>
<strong>Post processing</strong><small>Color grading and FXAA</small
><code>batchGraphUpdates()</code>
</div>
<b>+</b>
</button>
</div> </div>
</dialog> </dialog>