Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1fd4df540 | ||
|
|
df8b69e09d | ||
|
|
eb7508d1c1 | ||
|
|
d95e099fa4 | ||
|
|
7070799862 | ||
|
|
53a53a8f3f |
+1
-1
@@ -2,5 +2,5 @@
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
for command in npm cargo wasm-pack; do command -v "$command" >/dev/null; done
|
||||
for command in npm cargo wasm-pack go; do command -v "$command" >/dev/null; done
|
||||
amp orb services ensure
|
||||
|
||||
@@ -10,6 +10,10 @@ rustup toolchain install "$toolchain" --profile minimal --component rust-src,rus
|
||||
if ! command -v wasm-pack >/dev/null; then
|
||||
cargo install wasm-pack --version 0.15.0 --locked
|
||||
fi
|
||||
if ! command -v go >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y golang-go
|
||||
fi
|
||||
if ! command -v git-lfs >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y git-lfs
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
yawn-docs:
|
||||
command: npm start
|
||||
yawn-server:
|
||||
command: npm run server
|
||||
portal:
|
||||
title: Yawn
|
||||
description: Documentation and minimal WebGPU playground.
|
||||
description: Yawn website, tutorial, package server, and WebGPU playground.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.agents
|
||||
.amp
|
||||
.idea
|
||||
.vscode
|
||||
node_modules
|
||||
target
|
||||
dist
|
||||
core/pkg
|
||||
server/data
|
||||
docs
|
||||
coredocs
|
||||
vendor
|
||||
*.log
|
||||
*.local
|
||||
@@ -30,6 +30,8 @@ core/pkg
|
||||
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
server/data/
|
||||
yawn-data/
|
||||
|
||||
# Amp runtime artifacts
|
||||
.amp/in/
|
||||
|
||||
Generated
+23
@@ -117,12 +117,32 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
@@ -136,6 +156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
@@ -717,6 +738,8 @@ dependencies = [
|
||||
name = "yawn-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-bookworm AS package-builder
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential ca-certificates curl libssl-dev pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain stable
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
RUN cargo install wasm-pack --version 0.15.0 --locked
|
||||
|
||||
WORKDIR /src
|
||||
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||
COPY .cargo ./.cargo
|
||||
RUN toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" \
|
||||
&& rustup toolchain install "$toolchain" --profile minimal --component rust-src --target wasm32-unknown-unknown
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY core/package.json ./core/package.json
|
||||
COPY addons/handles/package.json ./addons/handles/package.json
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --include=dev
|
||||
|
||||
COPY core ./core
|
||||
COPY addons/handles ./addons/handles
|
||||
COPY server/build.mjs ./server/build.mjs
|
||||
RUN --mount=type=cache,target=/src/target mkdir -p /out \
|
||||
&& wasm-pack build core --target web --out-dir /out/pkg --release \
|
||||
&& node server/build.mjs /src /out
|
||||
|
||||
FROM golang:1.24-bookworm AS server-builder
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.work ./
|
||||
COPY server/go.mod server/go.sum ./server/
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY server ./server
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=1 go test ./server/... \
|
||||
&& CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/yawn-server ./server
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 10001 yawn \
|
||||
&& useradd --system --uid 10001 --gid yawn --home-dir /app --create-home yawn \
|
||||
&& mkdir -p /data \
|
||||
&& chown yawn:yawn /data
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=server-builder --chown=yawn:yawn /out/yawn-server /app/yawn-server
|
||||
COPY --from=package-builder --chown=yawn:yawn /out /app/packages
|
||||
COPY --from=package-builder --chown=yawn:yawn /src/node_modules/monaco-editor/min /app/node_modules/monaco-editor/min
|
||||
|
||||
ENV PORT=5002 \
|
||||
YAWN_ROOT=/app \
|
||||
YAWN_PACKAGES=/app/packages \
|
||||
YAWN_DATABASE=/data/yawn.db
|
||||
|
||||
USER yawn:yawn
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 5002
|
||||
STOPSIGNAL SIGTERM
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl --fail --silent --show-error "http://127.0.0.1:${PORT}/healthz" || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/yawn-server"]
|
||||
@@ -12,10 +12,45 @@ The arena starts with only one eight-float `signals` row for frame timing and re
|
||||
|
||||
WGSL, pipelines, glTF import, and conventional mesh/camera/material handles live in `addons/`; core contains no shader or scene model.
|
||||
|
||||
Run the Go website, in-memory package server, SQLite-backed playground, and tutorial:
|
||||
|
||||
```sh
|
||||
npm start
|
||||
npm run server
|
||||
```
|
||||
|
||||
This opens the docs. The complete runnable example is at `/playground`.
|
||||
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`. Handles keeps Core
|
||||
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
|
||||
`server/data/yawn.db` database path.
|
||||
|
||||
## VPS deployment
|
||||
|
||||
Clone the repository to `~/yawn` on a VPS with Docker and the Compose plugin, then run:
|
||||
|
||||
```sh
|
||||
cd ~/yawn
|
||||
./server/deploy-docker.sh
|
||||
```
|
||||
|
||||
The deploy script requires a clean `main`, fast-forwards it to `origin/main`, builds the production
|
||||
image, starts it with Docker Compose, and waits for the health check. Core, Handles, workers, and
|
||||
WASM are compiled while building the image; the runtime container only holds the Go server,
|
||||
prebuilt package assets, Monaco, and the persistent SQLite volume. Configure the port and database
|
||||
path in `compose.yaml`. The default port binding is private to the VPS at `127.0.0.1:5002`; put
|
||||
Caddy or nginx in front of it for HTTPS. Follow logs with `docker compose logs -f yawn`.
|
||||
|
||||
To check for new pushes every minute, add this entry with `crontab -e`:
|
||||
|
||||
```cron
|
||||
* * * * * $HOME/yawn/server/poll-deploy.sh >> $HOME/yawn-deploy.log 2>&1
|
||||
```
|
||||
|
||||
The poller compares `origin/main` with the last healthy deployed revision and does nothing when they
|
||||
match. A shared lock prevents overlapping manual and scheduled deployments. Failed deployments do
|
||||
not update the revision marker, so the next poll retries them.
|
||||
|
||||
Run the previous VitePress documentation site with `npm start`.
|
||||
|
||||
Run `npm run coredocs` for the raw worker-message, shared-memory, and render-graph reference intended for custom handles, editors, and direct SAB clients.
|
||||
|
||||
@@ -62,10 +62,14 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
if (id.x != 0u) { return; }
|
||||
var count = 0u;
|
||||
var light = vec3<f32>(0.0);
|
||||
var pointLight = vec3<f32>(0.0);
|
||||
var spotLight = vec3<f32>(0.0);
|
||||
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) {
|
||||
let enabled = pointLights[i * 2u + 1u].y;
|
||||
count += select(0u, 1u, enabled != 0.0);
|
||||
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++) {
|
||||
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++) {
|
||||
let enabled = spotLights[i * 2u + 1u].w;
|
||||
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++) {
|
||||
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[2] = bitcast<u32>(light.g);
|
||||
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 */ `
|
||||
@@ -173,6 +185,8 @@ struct VertexOutput {
|
||||
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
|
||||
@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(8) var<storage, read> pointLights: array<vec4<f32>>;
|
||||
@group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>;
|
||||
${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""}
|
||||
${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d<f32>;" : ""}
|
||||
${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d<f32>;" : ""}
|
||||
@@ -235,10 +249,38 @@ fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let f0 = mix(vec3(0.04), base.rgb, metallic);
|
||||
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 ambient = vec3(0.08) + clusterLight;
|
||||
let diffuse = base.rgb * (1.0 - metallic) * nDotL;
|
||||
let pointAmbient = vec3<f32>(bitcast<f32>(clusters[4]), bitcast<f32>(clusters[5]), bitcast<f32>(clusters[6]));
|
||||
let spotAmbient = vec3<f32>(bitcast<f32>(clusters[7]), bitcast<f32>(clusters[8]), bitcast<f32>(clusters[9]));
|
||||
let ambient = vec3(0.08) + max(clusterLight - pointAmbient - spotAmbient, vec3(0.0));
|
||||
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);
|
||||
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,
|
||||
resource,
|
||||
})),
|
||||
...(detailed
|
||||
? [
|
||||
{ group: 0, binding: 8, resource: "point-lights" },
|
||||
{ group: 0, binding: 9, resource: "spot-lights" },
|
||||
]
|
||||
: []),
|
||||
...(detailed && mask
|
||||
? [
|
||||
{ group: 1, binding: 0, resource: "material-linear" },
|
||||
|
||||
@@ -144,8 +144,16 @@ export class ArcRotateCamera extends Camera {
|
||||
if (this.#pointer.button === 2) {
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => {
|
||||
row[16] -= x * (this.#controls?.panSpeed ?? 0.005);
|
||||
row[17] += y * (this.#controls?.panSpeed ?? 0.005);
|
||||
const speed = this.#controls?.panSpeed ?? 0.005;
|
||||
const horizontal = -x * speed;
|
||||
const vertical = y * speed;
|
||||
const sinAlpha = Math.sin(row[13]);
|
||||
const cosAlpha = Math.cos(row[13]);
|
||||
const sinBeta = Math.sin(row[14]);
|
||||
const cosBeta = Math.cos(row[14]);
|
||||
row[16] += horizontal * cosAlpha - vertical * cosBeta * sinAlpha;
|
||||
row[17] += vertical * sinBeta;
|
||||
row[18] -= horizontal * sinAlpha + vertical * cosBeta * cosAlpha;
|
||||
});
|
||||
} else {
|
||||
const speed = this.#controls.orbitSpeed ?? 0.005;
|
||||
|
||||
@@ -100,12 +100,14 @@ export class Camera extends Node {
|
||||
const z = target[2] - position.z;
|
||||
const length = Math.hypot(x, y, z) || 1;
|
||||
const direction = [x / length, y / length, z / length];
|
||||
if (direction[2] > 0.999999) this.setRotor([0, 1, 0, 0]);
|
||||
else {
|
||||
const rotor = [direction[1], -direction[0], 0, 1 - direction[2]];
|
||||
const rotorLength = Math.hypot(...rotor);
|
||||
this.setRotor(rotor.map((lane) => lane / rotorLength));
|
||||
}
|
||||
const horizontal = Math.hypot(direction[0], direction[2]);
|
||||
const yaw = horizontal > 0.000001
|
||||
? Math.atan2(-direction[0], -direction[2])
|
||||
: 0;
|
||||
const pitch = Math.atan2(direction[1], horizontal);
|
||||
const sy = Math.sin(yaw / 2), cy = Math.cos(yaw / 2);
|
||||
const sx = Math.sin(pitch / 2), cx = Math.cos(pitch / 2);
|
||||
this.setRotor([sx * cy, cx * sy, -sx * sy, cx * cy]);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./Scene";
|
||||
export * from "./ComputePass";
|
||||
export * from "./Node";
|
||||
export * from "./Mesh";
|
||||
export * from "./shapes";
|
||||
export * from "./camera/Camera";
|
||||
export * from "./camera/ArcRotateCamera";
|
||||
export * from "./camera/FreeCamera";
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./Cube";
|
||||
export * from "./Icosphere";
|
||||
export * from "./Plane";
|
||||
export * from "./Sphere";
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
yawn:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: yawn:local
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
ports:
|
||||
- "127.0.0.1:5002:5002"
|
||||
environment:
|
||||
PORT: "5002"
|
||||
YAWN_DATABASE: /data/yawn.db
|
||||
volumes:
|
||||
- yawn-data:/data
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
stop_grace_period: 15s
|
||||
|
||||
volumes:
|
||||
yawn-data:
|
||||
@@ -9,6 +9,8 @@ crate-type = ["cdylib"]
|
||||
path = "lib.rs"
|
||||
|
||||
[dependencies]
|
||||
futures-channel = "0.3"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["async-await-macro", "std"] }
|
||||
js-sys = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
+21
-17
@@ -34,7 +34,7 @@ struct RowRequest {
|
||||
#[wasm_bindgen]
|
||||
pub struct Core {
|
||||
data: Rc<RefCell<RenderData>>,
|
||||
gpu: Rc<RefCell<Option<Wgpu>>>,
|
||||
gpu: Wgpu,
|
||||
store: Rc<RefCell<Store>>,
|
||||
render: Rc<RenderLoop>,
|
||||
}
|
||||
@@ -42,25 +42,26 @@ pub struct Core {
|
||||
#[wasm_bindgen]
|
||||
impl Core {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(arena_bytes: u32) -> Result<Self, JsError> {
|
||||
Ok(Self {
|
||||
data: Rc::new(RefCell::new(
|
||||
RenderData::new(arena_bytes).map_err(JsError::new)?,
|
||||
)),
|
||||
gpu: Rc::new(RefCell::new(None)),
|
||||
store: Rc::new(RefCell::new(Store::default())),
|
||||
render: Rc::new(RenderLoop::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn initialize(&self, canvas: web_sys::OffscreenCanvas) -> Result<(), JsError> {
|
||||
pub async fn new(arena_bytes: u32, canvas: web_sys::OffscreenCanvas) -> Result<Self, JsError> {
|
||||
let gpu = Wgpu::new(canvas)
|
||||
.await
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
*self.gpu.borrow_mut() = Some(gpu);
|
||||
self.render
|
||||
.start(self.gpu.clone(), self.store.clone(), self.data.clone());
|
||||
Ok(())
|
||||
|
||||
let render = Rc::new(RenderLoop::new());
|
||||
|
||||
let data = Rc::new(RefCell::new(
|
||||
RenderData::new(arena_bytes).map_err(JsError::new)?,
|
||||
));
|
||||
|
||||
// what does clone mean here
|
||||
render.start(&gpu, data.clone());
|
||||
|
||||
Ok(Self {
|
||||
data,
|
||||
gpu,
|
||||
store: Rc::new(RefCell::new(Store::default())),
|
||||
render,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> String {
|
||||
@@ -219,6 +220,9 @@ impl Core {
|
||||
.as_ref()
|
||||
.is_some_and(|gpu| gpu.timestamp_queries);
|
||||
self.render.set_profiling(enabled && supported);
|
||||
if enabled && supported {
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
}
|
||||
supported
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::gpu::Wgpu;
|
||||
use crate::graph::{Binding, Execution, Extent, Pass, RenderGraph, RenderPipeline, Texture};
|
||||
use crate::graph::{self, Binding, Execution, Extent, Pass, RenderGraph, RenderPipeline, Texture};
|
||||
use crate::render_data::RenderData;
|
||||
|
||||
pub mod profiler;
|
||||
|
||||
pub struct GpuResources {
|
||||
pub buffers: HashMap<String, GpuBuffer>,
|
||||
pub textures: Vec<GpuTexture>,
|
||||
@@ -18,21 +20,7 @@ pub struct GpuResources {
|
||||
pub render_pipelines: HashMap<String, wgpu::RenderPipeline>,
|
||||
pub compute_pipelines: HashMap<String, wgpu::ComputePipeline>,
|
||||
pub passes: Vec<GpuPass>,
|
||||
pub profiler: Option<GpuProfiler>,
|
||||
}
|
||||
|
||||
pub struct GpuProfiler {
|
||||
query_set: wgpu::QuerySet,
|
||||
resolve: wgpu::Buffer,
|
||||
readback: Arc<wgpu::Buffer>,
|
||||
query_count: u32,
|
||||
}
|
||||
|
||||
pub struct ProfileMap {
|
||||
readback: Arc<wgpu::Buffer>,
|
||||
state: Arc<AtomicU8>,
|
||||
labels: Vec<String>,
|
||||
timestamp_period: f32,
|
||||
pub profiler: Option<profiler::Profiler>,
|
||||
}
|
||||
|
||||
pub struct GpuBuffer {
|
||||
@@ -175,30 +163,6 @@ impl GpuResources {
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||
|
||||
let profiler = (gpu.timestamp_queries && !graph.executions.is_empty()).then(|| {
|
||||
let query_count = graph.executions.len() as u32 * 2;
|
||||
let bytes = u64::from(query_count) * 8;
|
||||
GpuProfiler {
|
||||
query_set: gpu.device.create_query_set(&wgpu::QuerySetDescriptor {
|
||||
label: Some("frame-profile"),
|
||||
ty: wgpu::QueryType::Timestamp,
|
||||
count: query_count,
|
||||
}),
|
||||
resolve: gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("frame-profile-resolve"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
readback: Arc::new(gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("frame-profile-readback"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
})),
|
||||
query_count,
|
||||
}
|
||||
});
|
||||
let mut resources = Self {
|
||||
buffers,
|
||||
textures,
|
||||
@@ -207,7 +171,7 @@ impl GpuResources {
|
||||
render_pipelines,
|
||||
compute_pipelines,
|
||||
passes: Vec::new(),
|
||||
profiler,
|
||||
profiler: None,
|
||||
};
|
||||
for execution in &graph.executions {
|
||||
let compiled = match execution {
|
||||
@@ -256,6 +220,17 @@ impl GpuResources {
|
||||
};
|
||||
resources.passes.push(compiled);
|
||||
}
|
||||
|
||||
let pass_labels = resources
|
||||
.passes
|
||||
.iter()
|
||||
.map(|pass| pass.label().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
resources.profiler = (gpu.timestamp_queries && !graph.executions.is_empty()).then(|| {
|
||||
profiler::Profiler::new(&gpu.device, graph.executions.len() as u32, pass_labels)
|
||||
});
|
||||
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
@@ -308,45 +283,6 @@ impl GpuResources {
|
||||
.is_some_and(|texture| !texture.uploaded_mips.contains(&mip_level))
|
||||
}
|
||||
|
||||
pub fn render_timestamps(&self, pass: usize) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
|
||||
self.profiler
|
||||
.as_ref()
|
||||
.map(|profiler| wgpu::RenderPassTimestampWrites {
|
||||
query_set: &profiler.query_set,
|
||||
beginning_of_pass_write_index: Some(pass as u32 * 2),
|
||||
end_of_pass_write_index: Some(pass as u32 * 2 + 1),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn compute_timestamps(&self, pass: usize) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
|
||||
self.profiler
|
||||
.as_ref()
|
||||
.map(|profiler| wgpu::ComputePassTimestampWrites {
|
||||
query_set: &profiler.query_set,
|
||||
beginning_of_pass_write_index: Some(pass as u32 * 2),
|
||||
end_of_pass_write_index: Some(pass as u32 * 2 + 1),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_profile(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(profiler) = &self.profiler else {
|
||||
return;
|
||||
};
|
||||
encoder.resolve_query_set(
|
||||
&profiler.query_set,
|
||||
0..profiler.query_count,
|
||||
&profiler.resolve,
|
||||
0,
|
||||
);
|
||||
encoder.copy_buffer_to_buffer(
|
||||
&profiler.resolve,
|
||||
0,
|
||||
&profiler.readback,
|
||||
0,
|
||||
u64::from(profiler.query_count) * 8,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn map_profile(&self, timestamp_period: f32) -> Option<ProfileMap> {
|
||||
let profiler = self.profiler.as_ref()?;
|
||||
let state = Arc::new(AtomicU8::new(0));
|
||||
@@ -448,11 +384,13 @@ impl GpuResources {
|
||||
.render_pipelines
|
||||
.get(&pass.pipeline)
|
||||
.ok_or("GRAPH_PIPELINE")?;
|
||||
|
||||
let pipeline_changed = previous_pipeline != Some(pass.pipeline.as_str());
|
||||
if pipeline_changed {
|
||||
encoder.set_pipeline(pipeline);
|
||||
previous_pipeline = Some(pass.pipeline.as_str());
|
||||
}
|
||||
|
||||
if pipeline_changed || previous_bindings != Some(pass.bindings.as_slice()) {
|
||||
for (group, bind_group) in
|
||||
self.bind_groups(pass, |group| pipeline.get_bind_group_layout(group), gpu)?
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
use serde::Serialize;
|
||||
|
||||
pub struct Profiler {
|
||||
pub query_set: wgpu::QuerySet,
|
||||
pub resolve: wgpu::Buffer,
|
||||
pub readback: wgpu::Buffer,
|
||||
pub query_count: u32,
|
||||
pub pass_labels: Vec<String>,
|
||||
}
|
||||
|
||||
impl Profiler {
|
||||
pub fn new(device: &wgpu::Device, render_pass_count: u32, pass_labels: Vec<String>) -> Self {
|
||||
let query_count = render_pass_count * 2;
|
||||
let size = u64::from(query_count) * 8;
|
||||
|
||||
Self {
|
||||
query_set: device.create_query_set(&wgpu::QuerySetDescriptor {
|
||||
label: Some("frame-profile"),
|
||||
ty: wgpu::QueryType::Timestamp,
|
||||
count: query_count,
|
||||
}),
|
||||
resolve: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("frame-profile-resolve"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
readback: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("frame-profile-readback"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
query_count,
|
||||
pass_labels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_query_set(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
encoder.resolve_query_set(&self.query_set, 0..self.query_count, &self.resolve, 0);
|
||||
encoder.copy_buffer_to_buffer(
|
||||
&self.resolve,
|
||||
0,
|
||||
&self.readback,
|
||||
0,
|
||||
u64::from(self.query_count) * 8,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn read_passes(self) -> Vec<Pass> {
|
||||
let bytes = self.readback.get_mapped_range(..);
|
||||
let values = bytes
|
||||
.chunks_exact(8)
|
||||
.map(|bytes| u64::from_le_bytes(bytes.try_into().unwrap()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let profile = self
|
||||
.pass_labels
|
||||
.into_iter()
|
||||
.zip(values.chunks_exact(2))
|
||||
.map(|(label, timestamps)| Pass {
|
||||
name: label,
|
||||
milliseconds: timestamps[1].saturating_sub(timestamps[0]) as f64
|
||||
* f64::from(self.timestamp_period)
|
||||
/ 1_000_000.0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
drop(bytes);
|
||||
self.readback.unmap();
|
||||
|
||||
profile
|
||||
}
|
||||
|
||||
pub async fn read_render_pass_data(&self) -> Result<Vec<Pass>, wgpu::BufferMapError> {
|
||||
self.readback
|
||||
.map_async(wgpu::MapMode::Read, .., |result| {
|
||||
result.and(self.read_passes());
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrameData {
|
||||
pub frame: u32,
|
||||
pub milliseconds: f64,
|
||||
pub readback_milliseconds: f64,
|
||||
pub adapter: String,
|
||||
pub canvas: Canvas,
|
||||
pub passes: Vec<Pass>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FrameData {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
serde_json::to_string(self)
|
||||
.map_err(|_| std::fmt::Error)?
|
||||
.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Canvas {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Pass {
|
||||
pub name: String,
|
||||
pub milliseconds: f64,
|
||||
}
|
||||
|
||||
pub enum ProfileState {
|
||||
Detached,
|
||||
Attached,
|
||||
Ready(FrameData),
|
||||
}
|
||||
|
||||
// pub struct ProfileMap {
|
||||
// readback: Arc<wgpu::Buffer>,
|
||||
// state: Arc<AtomicU8>,
|
||||
// labels: Vec<String>,
|
||||
// timestamp_period: f32,
|
||||
// }
|
||||
@@ -14,7 +14,6 @@ pub struct Loadout {
|
||||
pub struct Store {
|
||||
graphs: HashMap<String, RenderGraph>,
|
||||
texture_sources: HashMap<String, HashMap<u32, web_sys::ImageBitmap>>,
|
||||
active: Option<Loadout>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
|
||||
+78
-88
@@ -4,7 +4,7 @@ use std::rc::Rc;
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
use crate::gpu::Wgpu;
|
||||
use crate::gpu_resource::{GpuPass, ProfileMap};
|
||||
use crate::gpu_resource::{profiler, GpuPass, ProfileMap};
|
||||
use crate::graph::{ColorAttachment, DepthAttachment};
|
||||
use crate::render_data::RenderData;
|
||||
use crate::store::{Loadout, Store};
|
||||
@@ -39,10 +39,8 @@ pub struct RenderLoop {
|
||||
frame: Cell<u32>,
|
||||
elapsed: Cell<f64>,
|
||||
last: Cell<f64>,
|
||||
profiling: Cell<bool>,
|
||||
profile_pending: Cell<bool>,
|
||||
profile_after: Cell<f64>,
|
||||
profile: RefCell<Option<String>>,
|
||||
active_loadout: Loadout,
|
||||
profile_state: RefCell<profiler::ProfileState>,
|
||||
}
|
||||
|
||||
impl RenderLoop {
|
||||
@@ -54,10 +52,8 @@ impl RenderLoop {
|
||||
frame: Cell::new(0),
|
||||
elapsed: Cell::new(0.0),
|
||||
last: Cell::new(js_sys::Date::now()),
|
||||
profiling: Cell::new(false),
|
||||
profile_pending: Cell::new(false),
|
||||
profile_after: Cell::new(0.0),
|
||||
profile: RefCell::new(None),
|
||||
active_loadout: todo!(),
|
||||
profile_state: RefCell::new(profiler::ProfileState::Detached),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,24 +74,19 @@ impl RenderLoop {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_profiling(&self, enabled: bool) {
|
||||
self.profiling.set(enabled);
|
||||
self.profile_after.set(0.0);
|
||||
if !enabled {
|
||||
self.profile.borrow_mut().take();
|
||||
}
|
||||
pub fn set_profiling(&mut self) {
|
||||
self.profile_state = RefCell::new(profiler::ProfileState::Attached);
|
||||
}
|
||||
|
||||
pub fn take_profile(&self) -> Option<String> {
|
||||
self.profile.borrow_mut().take()
|
||||
if let profiler::ProfileState::Ready(data) = self.profile_state.get_mut().take() {
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
self: &Rc<Self>,
|
||||
gpu: Rc<RefCell<Option<Wgpu>>>,
|
||||
store: Rc<RefCell<Store>>,
|
||||
data: Rc<RefCell<RenderData>>,
|
||||
) {
|
||||
pub fn start(self: &Rc<Self>, gpu: &Wgpu, data: Rc<RefCell<RenderData>>) {
|
||||
if self.started.replace(true) {
|
||||
return;
|
||||
}
|
||||
@@ -106,6 +97,7 @@ impl RenderLoop {
|
||||
if !control.playing.get() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let started = js_sys::Date::now();
|
||||
let fps = control.fps.get();
|
||||
if fps != 0 && started - control.last.get() < 900.0 / f64::from(fps) {
|
||||
@@ -120,36 +112,25 @@ impl RenderLoop {
|
||||
let mut data = data.borrow_mut();
|
||||
data.update_signals(delta as f32, frame, elapsed as f32, control.fps.get());
|
||||
}
|
||||
let submission = if data.borrow().render_pending() {
|
||||
if let (Some(gpu), Some(loadout)) =
|
||||
(gpu.borrow_mut().as_mut(), store.borrow_mut().active_mut())
|
||||
{
|
||||
if data.borrow_mut().begin_render() {
|
||||
let profile = control.profiling.get()
|
||||
&& !control.profile_pending.get()
|
||||
&& started >= control.profile_after.get();
|
||||
let result = {
|
||||
let data = data.borrow();
|
||||
gpu.render(loadout, &data, profile)
|
||||
};
|
||||
match result {
|
||||
Ok(profile) => profile.map(|profile| {
|
||||
(profile, gpu.adapter.clone(), gpu.width, gpu.height)
|
||||
}),
|
||||
Err(_) => {
|
||||
data.borrow_mut().mark_dirty();
|
||||
None
|
||||
|
||||
// returns false when no render is pending
|
||||
if !data.borrow_mut().begin_render() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let should_profile = matches!(
|
||||
*control.profile_state.borrow(),
|
||||
profiler::ProfileState::Attached
|
||||
);
|
||||
|
||||
gpu.render(&mut self.active_loadout, &data.borrow(), should_profile)
|
||||
.and_then(|profile_metadata| {
|
||||
// we are done with render loop but we got a profile data for this
|
||||
// frame, so we need to process that
|
||||
|
||||
control.profile_state
|
||||
});
|
||||
|
||||
if let Some((profile, adapter, width, height)) = submission {
|
||||
control.profile_pending.set(true);
|
||||
control.profile_after.set(started + 250.0);
|
||||
@@ -162,27 +143,20 @@ impl RenderLoop {
|
||||
let passes = profile
|
||||
.read()
|
||||
.into_iter()
|
||||
.map(|(name, milliseconds)| {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"milliseconds": milliseconds,
|
||||
})
|
||||
})
|
||||
.map(|(name, milliseconds)| profiler::Pass { name, milliseconds })
|
||||
.collect::<Vec<_>>();
|
||||
let milliseconds = passes
|
||||
.iter()
|
||||
.filter_map(|pass| pass["milliseconds"].as_f64())
|
||||
.sum::<f64>();
|
||||
let milliseconds =
|
||||
passes.iter().map(|pass| pass.milliseconds).sum::<f64>();
|
||||
if control.profiling.get() {
|
||||
*control.profile.borrow_mut() = Some(
|
||||
serde_json::json!({
|
||||
"frame": frame,
|
||||
"milliseconds": milliseconds,
|
||||
"readbackMilliseconds": js_sys::Date::now() - started,
|
||||
"adapter": adapter,
|
||||
"canvas": { "width": width, "height": height },
|
||||
"passes": passes,
|
||||
})
|
||||
*control.profile_state.borrow_mut() = Some(
|
||||
profiler::FrameData {
|
||||
frame,
|
||||
milliseconds,
|
||||
readback_milliseconds: js_sys::Date::now() - started,
|
||||
adapter,
|
||||
canvas: profiler::Canvas { width, height },
|
||||
passes,
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -201,7 +175,7 @@ impl Wgpu {
|
||||
loadout: &mut Loadout,
|
||||
data: &RenderData,
|
||||
profile: bool,
|
||||
) -> Result<Option<ProfileMap>, String> {
|
||||
) -> Result<ProfileMap, String> {
|
||||
for buffer in loadout.resources.buffers.values() {
|
||||
if !buffer.sync_each_frame {
|
||||
continue;
|
||||
@@ -212,6 +186,7 @@ impl Wgpu {
|
||||
data.bytes(&buffer.source).ok_or("ROWS_UNKNOWN")?,
|
||||
);
|
||||
}
|
||||
|
||||
let output = match self.surface.get_current_texture() {
|
||||
Ok(output) => output,
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
@@ -221,9 +196,21 @@ impl Wgpu {
|
||||
Err(wgpu::SurfaceError::Timeout) => return Err("SURFACE".into()),
|
||||
Err(_) => return Err("SURFACE".into()),
|
||||
};
|
||||
|
||||
let surface_view = output
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
// profiler is used only when enabled
|
||||
// the presence of a profiler means:
|
||||
// - profiler is available and
|
||||
// - we want to profile this frame
|
||||
let profiler = if profile {
|
||||
loadout.resources.profiler
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
@@ -238,9 +225,12 @@ impl Wgpu {
|
||||
..
|
||||
} => {
|
||||
let pass = &loadout.graph.passes[*pass];
|
||||
let timestamp_writes = profile
|
||||
.then(|| loadout.resources.compute_timestamps(pass_index))
|
||||
.flatten();
|
||||
let timestamp_writes = profiler.and(|p| wgpu::ComputePassTimestampWrites {
|
||||
query_set: &p.query_set,
|
||||
beginning_of_pass_write_index: Some(pass_index as u32 * 2),
|
||||
end_of_pass_write_index: Some(pass_index as u32 + 1),
|
||||
});
|
||||
|
||||
let mut command = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some(&pass.id),
|
||||
timestamp_writes,
|
||||
@@ -296,9 +286,13 @@ impl Wgpu {
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let timestamp_writes = profile
|
||||
.then(|| loadout.resources.render_timestamps(pass_index))
|
||||
.flatten();
|
||||
|
||||
let timestamp_writes = profiler.and(|p| wgpu::RenderPassTimestampWrites {
|
||||
query_set: &p.query_set,
|
||||
beginning_of_pass_write_index: Some(pass_index as u32 * 2),
|
||||
end_of_pass_write_index: Some(pass_index as u32 * 2 + 1),
|
||||
});
|
||||
|
||||
let mut command = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(&pass.id),
|
||||
color_attachments: &colors,
|
||||
@@ -310,19 +304,15 @@ impl Wgpu {
|
||||
}
|
||||
}
|
||||
}
|
||||
if profile {
|
||||
loadout.resources.resolve_profile(&mut encoder);
|
||||
}
|
||||
|
||||
profiler.as_ref().map(|p| p.encode_query_set(&mut encoder));
|
||||
self.queue.submit([encoder.finish()]);
|
||||
let profile = profile
|
||||
.then(|| {
|
||||
loadout
|
||||
.resources
|
||||
.map_profile(self.queue.get_timestamp_period())
|
||||
})
|
||||
.flatten();
|
||||
|
||||
output.present();
|
||||
Ok(profile)
|
||||
|
||||
profiler
|
||||
.and_then(|p| p.map_profile(self.queue.get_timestamp_period()))
|
||||
.flatten();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+493
@@ -15,6 +15,8 @@
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"codemirror": "^6.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"vitepress": "^1.6.4"
|
||||
}
|
||||
},
|
||||
@@ -777,6 +779,40 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
@@ -794,6 +830,23 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
@@ -811,6 +864,23 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
@@ -1562,6 +1632,422 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
||||
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.9",
|
||||
"@esbuild/android-arm": "0.25.9",
|
||||
"@esbuild/android-arm64": "0.25.9",
|
||||
"@esbuild/android-x64": "0.25.9",
|
||||
"@esbuild/darwin-arm64": "0.25.9",
|
||||
"@esbuild/darwin-x64": "0.25.9",
|
||||
"@esbuild/freebsd-arm64": "0.25.9",
|
||||
"@esbuild/freebsd-x64": "0.25.9",
|
||||
"@esbuild/linux-arm": "0.25.9",
|
||||
"@esbuild/linux-arm64": "0.25.9",
|
||||
"@esbuild/linux-ia32": "0.25.9",
|
||||
"@esbuild/linux-loong64": "0.25.9",
|
||||
"@esbuild/linux-mips64el": "0.25.9",
|
||||
"@esbuild/linux-ppc64": "0.25.9",
|
||||
"@esbuild/linux-riscv64": "0.25.9",
|
||||
"@esbuild/linux-s390x": "0.25.9",
|
||||
"@esbuild/linux-x64": "0.25.9",
|
||||
"@esbuild/netbsd-arm64": "0.25.9",
|
||||
"@esbuild/netbsd-x64": "0.25.9",
|
||||
"@esbuild/openbsd-arm64": "0.25.9",
|
||||
"@esbuild/openbsd-x64": "0.25.9",
|
||||
"@esbuild/openharmony-arm64": "0.25.9",
|
||||
"@esbuild/sunos-x64": "0.25.9",
|
||||
"@esbuild/win32-arm64": "0.25.9",
|
||||
"@esbuild/win32-ia32": "0.25.9",
|
||||
"@esbuild/win32-x64": "0.25.9"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
|
||||
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
|
||||
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
|
||||
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
|
||||
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
@@ -1810,6 +2296,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
|
||||
+7
-1
@@ -3,9 +3,13 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["core", "addons/*"],
|
||||
"workspaces": [
|
||||
"core",
|
||||
"addons/*"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "wasm-pack build core --target web --out-dir pkg --release && vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}",
|
||||
"server": "go run ./server",
|
||||
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
|
||||
"build:coredocs": "vitepress build coredocs"
|
||||
},
|
||||
@@ -13,6 +17,8 @@
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"codemirror": "^6.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"vitepress": "^1.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
var webFiles embed.FS
|
||||
|
||||
type application struct {
|
||||
store *playgroundStore
|
||||
packages map[string]memoryAsset
|
||||
monaco http.Handler
|
||||
assets http.Handler
|
||||
pages map[string][]byte
|
||||
}
|
||||
|
||||
func newApplication(store *playgroundStore, packages map[string]memoryAsset, monacoDirectory string) (*application, error) {
|
||||
if info, err := os.Stat(monacoDirectory); err != nil || !info.IsDir() {
|
||||
return nil, fmt.Errorf("Monaco assets not found at %s; run npm install", monacoDirectory)
|
||||
}
|
||||
assets, err := fs.Sub(webFiles, "web/assets")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages := make(map[string][]byte)
|
||||
for _, name := range []string{"home.html", "docs.html", "playground.html"} {
|
||||
content, err := webFiles.ReadFile("web/" + name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages[name] = content
|
||||
}
|
||||
return &application{
|
||||
store: store,
|
||||
packages: packages,
|
||||
monaco: http.StripPrefix("/assets/monaco/", http.FileServer(http.Dir(monacoDirectory))),
|
||||
assets: http.StripPrefix("/assets/", http.FileServer(http.FS(assets))),
|
||||
pages: pages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *application) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", app.health)
|
||||
mux.HandleFunc("/pkg/", app.servePackage)
|
||||
mux.HandleFunc("/api/playgrounds", app.savePlayground)
|
||||
mux.HandleFunc("/api/playgrounds/", app.getPlayground)
|
||||
mux.Handle("/assets/monaco/", app.monaco)
|
||||
mux.Handle("/assets/", app.assets)
|
||||
mux.HandleFunc("/docs", app.servePage("docs.html"))
|
||||
mux.HandleFunc("/docs/", app.servePage("docs.html"))
|
||||
mux.HandleFunc("/playground", app.servePlayground)
|
||||
mux.HandleFunc("/playground/", app.servePlayground)
|
||||
mux.HandleFunc("/", app.serveHome)
|
||||
return app.headers(mux)
|
||||
}
|
||||
|
||||
func (app *application) health(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
||||
response.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) headers(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
response.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
|
||||
response.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
response.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
response.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
next.ServeHTTP(response, request)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) serveHome(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/" {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
app.writePage(response, request, "home.html")
|
||||
}
|
||||
|
||||
func (app *application) servePage(name string) http.HandlerFunc {
|
||||
return func(response http.ResponseWriter, request *http.Request) {
|
||||
app.writePage(response, request, name)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) servePlayground(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/playground" && request.URL.Path != "/playground/" {
|
||||
parts := strings.Split(strings.Trim(request.URL.Path, "/"), "/")
|
||||
if len(parts) != 3 || parts[0] != "playground" || !playgroundIDPattern.MatchString(parts[1]) {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
if revision, err := strconv.Atoi(parts[2]); err != nil || revision < 1 {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
}
|
||||
app.writePage(response, request, "playground.html")
|
||||
}
|
||||
|
||||
func (app *application) writePage(response http.ResponseWriter, request *http.Request, name string) {
|
||||
response.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
response.Header().Set("Cache-Control", "no-cache")
|
||||
if request.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
if request.Method != http.MethodGet {
|
||||
response.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
_, _ = response.Write(app.pages[name])
|
||||
}
|
||||
|
||||
func (app *application) servePackage(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
||||
response.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(path.Clean(request.URL.Path), "/pkg/")
|
||||
asset, exists := app.packages[name]
|
||||
if !exists || name == "." {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
response.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
response.Header().Set("Cache-Control", "no-cache")
|
||||
response.Header().Set("Content-Type", asset.contentType)
|
||||
response.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
response.Header().Set("ETag", asset.etag)
|
||||
if request.Header.Get("If-None-Match") == asset.etag {
|
||||
response.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
http.ServeContent(response, request, name, asset.modified, bytes.NewReader(asset.content))
|
||||
}
|
||||
|
||||
func (app *application) savePlayground(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost {
|
||||
response.Header().Set("Allow", "POST")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
request.Body = http.MaxBytesReader(response, request.Body, maximumCodeBytes+4096)
|
||||
var input struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
writeJSONError(response, "The save request is not valid.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := app.store.save(request.Context(), input.ID, strings.TrimSpace(input.Title), input.Code)
|
||||
if err != nil {
|
||||
writeJSONError(response, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(response, result, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (app *application) getPlayground(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet {
|
||||
response.Header().Set("Allow", "GET")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(request.URL.Path, "/api/playgrounds/"), "/")
|
||||
if len(parts) != 2 {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
revision, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
result, err := app.store.get(request.Context(), parts[0], revision)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSONError(response, "Playground not found.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSONError(response, "Could not load the playground.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
writeJSON(response, result, http.StatusOK)
|
||||
}
|
||||
|
||||
func writeJSON(response http.ResponseWriter, value any, status int) {
|
||||
response.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
response.WriteHeader(status)
|
||||
_ = json.NewEncoder(response).Encode(value)
|
||||
}
|
||||
|
||||
func writeJSONError(response http.ResponseWriter, message string, status int) {
|
||||
writeJSON(response, map[string]string{"error": message}, status)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testApplication(t *testing.T) *application {
|
||||
t.Helper()
|
||||
store, err := openPlaygroundStore(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
monaco := filepath.Join(t.TempDir(), "monaco")
|
||||
if err := os.MkdirAll(monaco, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := newApplication(store, map[string]memoryAsset{
|
||||
"core.js": {
|
||||
contentType: "text/javascript; charset=utf-8",
|
||||
content: []byte("export const ready = true"),
|
||||
etag: `"core"`,
|
||||
modified: time.Unix(0, 0),
|
||||
},
|
||||
}, monaco)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func TestPackageIsServedFromMemory(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/pkg/core.js", nil)
|
||||
response := httptest.NewRecorder()
|
||||
testApplication(t).routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if response.Body.String() != "export const ready = true" {
|
||||
t.Fatalf("body = %q", response.Body.String())
|
||||
}
|
||||
if response.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" {
|
||||
t.Fatal("cross-origin isolation header is missing")
|
||||
}
|
||||
if response.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Fatal("package CORS header is missing")
|
||||
}
|
||||
if response.Header().Get("Cross-Origin-Resource-Policy") != "cross-origin" {
|
||||
t.Fatal("package resource policy is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
response := httptest.NewRecorder()
|
||||
testApplication(t).routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadPlaygroundRevision(t *testing.T) {
|
||||
app := testApplication(t)
|
||||
handler := app.routes()
|
||||
requestBody, _ := json.Marshal(map[string]string{
|
||||
"title": "First scene",
|
||||
"code": "const scene = true;",
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/playgrounds", bytes.NewReader(requestBody))
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("save status = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var saved playground
|
||||
if err := json.NewDecoder(response.Body).Decode(&saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Revision != 1 || !playgroundIDPattern.MatchString(saved.ID) {
|
||||
t.Fatalf("saved = %#v", saved)
|
||||
}
|
||||
requestBody, _ = json.Marshal(map[string]string{
|
||||
"id": saved.ID,
|
||||
"title": "Second scene",
|
||||
"code": "const scene = false;",
|
||||
})
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/playgrounds", bytes.NewReader(requestBody))
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("second save status = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var second playground
|
||||
if err := json.NewDecoder(response.Body).Decode(&second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.ID != saved.ID || second.Revision != 2 {
|
||||
t.Fatalf("second = %#v", second)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/playgrounds/"+saved.ID+"/1", nil)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("load status = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var loaded playground
|
||||
if err := json.NewDecoder(response.Body).Decode(&loaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Code != "const scene = true;" || loaded.Title != "First scene" {
|
||||
t.Fatalf("loaded = %#v", loaded)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/playgrounds/"+saved.ID+"/2", nil)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("second load status = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&loaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Code != "const scene = false;" || loaded.Title != "Second scene" {
|
||||
t.Fatalf("second loaded = %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedPlaygroundPageRoute(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/playground/abcdef12/1", nil)
|
||||
response := httptest.NewRecorder()
|
||||
testApplication(t).routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if contentType := response.Header().Get("Content-Type"); contentType != "text/html; charset=utf-8" {
|
||||
t.Fatalf("content type = %q", contentType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const [root, output] = process.argv.slice(2);
|
||||
if (!root || !output)
|
||||
throw new Error("usage: node build.mjs <repository> <output>");
|
||||
|
||||
const browser = {
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
legalComments: "none",
|
||||
minify: true,
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
};
|
||||
|
||||
const workerFiles = new Map([
|
||||
[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",
|
||||
],
|
||||
]);
|
||||
|
||||
const workerURLs = {
|
||||
name: "yawn-worker-urls",
|
||||
setup(builder) {
|
||||
builder.onLoad({ filter: /\.(js|ts)$/ }, async ({ path: source }) => {
|
||||
const worker = workerFiles.get(path.normalize(source));
|
||||
if (!worker) return;
|
||||
let contents = await fs.readFile(source, "utf8");
|
||||
const transformed = contents.replace(
|
||||
/new Worker\(new URL\("\.\/worker\.(?:js|ts)", import\.meta\.url\),/,
|
||||
`createYawnWorker(new URL(${JSON.stringify(worker)}, import.meta.url),`,
|
||||
);
|
||||
if (transformed === contents)
|
||||
throw new Error(`worker constructor not found in ${source}`);
|
||||
contents = `
|
||||
function createYawnWorker(url, options) {
|
||||
if (url.origin === globalThis.location.origin) return new Worker(url, options);
|
||||
const source = "import " + JSON.stringify(url.href) + ";";
|
||||
const bootstrap = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
||||
const worker = new Worker(bootstrap, options);
|
||||
URL.revokeObjectURL(bootstrap);
|
||||
return worker;
|
||||
}
|
||||
${transformed}`;
|
||||
return { contents, loader: path.extname(source) === ".ts" ? "ts" : "js" };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
build({
|
||||
...browser,
|
||||
entryPoints: [path.join(root, "core", "index.js")],
|
||||
outfile: path.join(output, "core.js"),
|
||||
plugins: [workerURLs],
|
||||
}),
|
||||
build({
|
||||
...browser,
|
||||
entryPoints: [path.join(root, "addons", "handles", "src", "index.ts")],
|
||||
external: ["@yawn/core"],
|
||||
outfile: path.join(output, "handles.js"),
|
||||
plugins: [workerURLs],
|
||||
}),
|
||||
build({
|
||||
bundle: false,
|
||||
entryPoints: [path.join(root, "core", "worker.js")],
|
||||
format: "esm",
|
||||
legalComments: "none",
|
||||
minify: true,
|
||||
outfile: path.join(output, "worker.js"),
|
||||
target: "es2022",
|
||||
}),
|
||||
build({
|
||||
...browser,
|
||||
entryPoints: [
|
||||
path.join(root, "addons", "handles", "src", "importers", "worker.ts"),
|
||||
],
|
||||
outfile: path.join(output, "importer-worker.js"),
|
||||
}),
|
||||
build({
|
||||
...browser,
|
||||
entryPoints: [
|
||||
path.join(root, "addons", "handles", "src", "bvh", "worker.ts"),
|
||||
],
|
||||
outfile: path.join(output, "bvh-worker.js"),
|
||||
}),
|
||||
]);
|
||||
@@ -0,0 +1,153 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const (
|
||||
maximumCodeBytes = 200 * 1024
|
||||
maximumTitleRunes = 80
|
||||
)
|
||||
|
||||
var playgroundIDPattern = regexp.MustCompile(`^[a-z0-9]{6,24}$`)
|
||||
|
||||
type playground struct {
|
||||
ID string `json:"id"`
|
||||
Revision int `json:"revision"`
|
||||
Title string `json:"title"`
|
||||
Code string `json:"code"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type playgroundStore struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func openPlaygroundStore(path string) (*playgroundStore, error) {
|
||||
if path != ":memory:" {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
database, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
database.SetMaxOpenConns(1)
|
||||
store := &playgroundStore{DB: database}
|
||||
if err := store.initialize(); err != nil {
|
||||
database.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (store *playgroundStore) initialize() error {
|
||||
_, err := store.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS playgrounds (
|
||||
id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||
title TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (id, revision)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS playgrounds_created_at
|
||||
ON playgrounds (created_at DESC);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *playgroundStore) save(ctx context.Context, id, title, code string) (playground, error) {
|
||||
if id != "" && !playgroundIDPattern.MatchString(id) {
|
||||
return playground{}, errors.New("invalid playground id")
|
||||
}
|
||||
if title == "" || utf8.RuneCountInString(title) > maximumTitleRunes {
|
||||
return playground{}, fmt.Errorf("title must contain 1 to %d characters", maximumTitleRunes)
|
||||
}
|
||||
if code == "" || len(code) > maximumCodeBytes || !utf8.ValidString(code) {
|
||||
return playground{}, fmt.Errorf("code must contain 1 to %d bytes of UTF-8", maximumCodeBytes)
|
||||
}
|
||||
if id == "" {
|
||||
var err error
|
||||
id, err = randomPlaygroundID()
|
||||
if err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
}
|
||||
|
||||
transaction, err := store.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
|
||||
var revision int
|
||||
if err := transaction.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT COALESCE(MAX(revision), 0) + 1 FROM playgrounds WHERE id = ?`,
|
||||
id,
|
||||
).Scan(&revision); err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
createdAt := time.Now().UTC().Truncate(time.Millisecond)
|
||||
if _, err := transaction.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO playgrounds (id, revision, title, code, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
id,
|
||||
revision,
|
||||
title,
|
||||
code,
|
||||
createdAt.Format(time.RFC3339Nano),
|
||||
); err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
return playground{ID: id, Revision: revision, Title: title, Code: code, CreatedAt: createdAt}, nil
|
||||
}
|
||||
|
||||
func (store *playgroundStore) get(ctx context.Context, id string, revision int) (playground, error) {
|
||||
if !playgroundIDPattern.MatchString(id) || revision < 1 {
|
||||
return playground{}, sql.ErrNoRows
|
||||
}
|
||||
var result playground
|
||||
var createdAt string
|
||||
err := store.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, revision, title, code, created_at FROM playgrounds WHERE id = ? AND revision = ?`,
|
||||
id,
|
||||
revision,
|
||||
).Scan(&result.ID, &result.Revision, &result.Title, &result.Code, &createdAt)
|
||||
if err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
result.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func randomPlaygroundID() (string, error) {
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"
|
||||
result := make([]byte, 10)
|
||||
for index := range result {
|
||||
value, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result[index] = alphabet[value.Int64()]
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repository="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
expected_repository="$HOME/yawn"
|
||||
state_directory="$HOME/.local/state/yawn"
|
||||
deployed_revision_file="$state_directory/deployed-revision"
|
||||
compose=(docker compose --project-name yawn --project-directory "$repository" --file "$repository/compose.yaml")
|
||||
|
||||
if [[ "$repository" != "$expected_repository" ]]; then
|
||||
printf 'Expected the repository at %s, found %s\n' "$expected_repository" "$repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command in git docker flock; do
|
||||
if ! command -v "$command" >/dev/null 2>&1; then
|
||||
printf 'Missing required command: %s\n' "$command" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
printf 'Docker Compose v2 is required.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$state_directory"
|
||||
exec 9>"$state_directory/deploy.lock"
|
||||
if ! flock -n 9; then
|
||||
printf 'Another Yawn deployment is already running.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$repository"
|
||||
|
||||
if [[ "$(git branch --show-current)" != "main" ]]; then
|
||||
printf 'Deployments must run from the main branch.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then
|
||||
printf 'Refusing to deploy a checkout with local changes.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Updating main...\n'
|
||||
git fetch origin main
|
||||
fetched="$(git rev-parse FETCH_HEAD)"
|
||||
git merge --ff-only "$fetched"
|
||||
if [[ "$(git rev-parse HEAD)" != "$fetched" ]]; then
|
||||
printf 'HEAD does not match origin/main after updating.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Building the production image...\n'
|
||||
"${compose[@]}" build --pull yawn
|
||||
"${compose[@]}" up -d --remove-orphans yawn
|
||||
|
||||
container="$("${compose[@]}" ps -q yawn)"
|
||||
if [[ -z "$container" ]]; then
|
||||
printf 'Docker Compose did not create the Yawn container.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Waiting for Yawn to become healthy...\n'
|
||||
for _ in {1..60}; do
|
||||
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container")"
|
||||
case "$status" in
|
||||
healthy)
|
||||
temporary_revision="$(mktemp "$state_directory/deployed-revision.XXXXXX")"
|
||||
git rev-parse HEAD >"$temporary_revision"
|
||||
mv -f -- "$temporary_revision" "$deployed_revision_file"
|
||||
printf 'The Yawn container is healthy.\n'
|
||||
exit 0
|
||||
;;
|
||||
unhealthy | exited | dead)
|
||||
"${compose[@]}" logs --tail 50 yawn >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
sleep 2
|
||||
done
|
||||
|
||||
printf 'Yawn did not become healthy before the timeout.\n' >&2
|
||||
"${compose[@]}" logs --tail 50 yawn >&2
|
||||
exit 1
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.heaust.org/heaust/yawn/server
|
||||
|
||||
go 1.19
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.32
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
root, err := repositoryRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
packageDirectory := os.Getenv("YAWN_PACKAGES")
|
||||
var packages map[string]memoryAsset
|
||||
if packageDirectory == "" {
|
||||
log.Print("building core and handles packages")
|
||||
packages, err = buildPackages(root)
|
||||
} else {
|
||||
log.Printf("loading prebuilt packages from %s", packageDirectory)
|
||||
packages, err = loadPackages(packageDirectory)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare packages: %w", err)
|
||||
}
|
||||
log.Printf("loaded %d package assets into memory", len(packages))
|
||||
|
||||
databasePath := os.Getenv("YAWN_DATABASE")
|
||||
if databasePath == "" {
|
||||
databasePath = filepath.Join(root, "server", "data", "yawn.db")
|
||||
}
|
||||
store, err := openPlaygroundStore(databasePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open playground database: %w", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
app, err := newApplication(
|
||||
store,
|
||||
packages,
|
||||
filepath.Join(root, "node_modules", "monaco-editor", "min"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: app.routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
stopped := make(chan os.Signal, 1)
|
||||
signal.Notify(stopped, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-stopped
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Yawn server listening on http://0.0.0.0:%s", port)
|
||||
err = server.ListenAndServe()
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func repositoryRoot() (string, error) {
|
||||
if configured := os.Getenv("YAWN_ROOT"); configured != "" {
|
||||
root, err := filepath.Abs(configured)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("YAWN_ROOT is not a directory: %s", root)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
directory, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
if fileExists(filepath.Join(directory, "Cargo.toml")) &&
|
||||
fileExists(filepath.Join(directory, "core", "Cargo.toml")) &&
|
||||
fileExists(filepath.Join(directory, "addons", "handles", "package.json")) {
|
||||
return directory, nil
|
||||
}
|
||||
parent := filepath.Dir(directory)
|
||||
if parent == directory {
|
||||
return "", errors.New("could not find the Yawn repository root")
|
||||
}
|
||||
directory = parent
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type memoryAsset struct {
|
||||
contentType string
|
||||
content []byte
|
||||
etag string
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
func buildPackages(root string) (map[string]memoryAsset, error) {
|
||||
output, err := os.MkdirTemp("", "yawn-packages-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(output)
|
||||
|
||||
if err := runBuild(root, "wasm-pack", "build", filepath.Join(root, "core"),
|
||||
"--target", "web", "--out-dir", filepath.Join(output, "pkg"), "--release"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := runBuild(root, "node", filepath.Join(root, "server", "build.mjs"), root, output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadPackages(output)
|
||||
}
|
||||
|
||||
func loadPackages(directory string) (map[string]memoryAsset, error) {
|
||||
assets := make(map[string]memoryAsset)
|
||||
for _, name := range []string{
|
||||
"core.js",
|
||||
"handles.js",
|
||||
"worker.js",
|
||||
"importer-worker.js",
|
||||
"bvh-worker.js",
|
||||
} {
|
||||
asset, err := loadMemoryAsset(filepath.Join(directory, name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assets[name] = asset
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(filepath.Join(directory, "pkg"), func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relative, err := filepath.Rel(filepath.Join(directory, "pkg"), path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
asset, err := loadMemoryAsset(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assets[filepath.ToSlash(filepath.Join("pkg", relative))] = asset
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func runBuild(directory, name string, arguments ...string) error {
|
||||
command := exec.Command(name, arguments...)
|
||||
command.Dir = directory
|
||||
command.Env = os.Environ()
|
||||
var output bytes.Buffer
|
||||
command.Stdout = &output
|
||||
command.Stderr = &output
|
||||
if err := command.Run(); err != nil {
|
||||
return fmt.Errorf("%s: %w\n%s", name, err, output.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadMemoryAsset(path string) (memoryAsset, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return memoryAsset{}, err
|
||||
}
|
||||
digest := sha256.Sum256(content)
|
||||
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
return memoryAsset{
|
||||
contentType: contentType,
|
||||
content: content,
|
||||
etag: fmt.Sprintf("\"%x\"", digest[:12]),
|
||||
modified: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadPackages(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
for _, name := range []string{
|
||||
"core.js",
|
||||
"handles.js",
|
||||
"worker.js",
|
||||
"importer-worker.js",
|
||||
"bvh-worker.js",
|
||||
filepath.Join("pkg", "yawn_core_bg.wasm"),
|
||||
} {
|
||||
path := filepath.Join(directory, name)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
packages, err := loadPackages(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(packages) != 6 {
|
||||
t.Fatalf("package count = %d", len(packages))
|
||||
}
|
||||
if content := string(packages["pkg/yawn_core_bg.wasm"].content); content != filepath.Join("pkg", "yawn_core_bg.wasm") {
|
||||
t.Fatalf("WASM content = %q", content)
|
||||
}
|
||||
}
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repository="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
expected_repository="$HOME/yawn"
|
||||
state_directory="$HOME/.local/state/yawn"
|
||||
deployed_revision_file="$state_directory/deployed-revision"
|
||||
compose=(docker compose --project-name yawn --project-directory "$repository" --file "$repository/compose.yaml")
|
||||
|
||||
if [[ "$repository" != "$expected_repository" ]]; then
|
||||
printf 'Expected the repository at %s, found %s\n' "$expected_repository" "$repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command in git docker flock; do
|
||||
if ! command -v "$command" >/dev/null 2>&1; then
|
||||
printf 'Missing required command: %s\n' "$command" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
printf 'Docker Compose v2 is required.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$state_directory"
|
||||
exec 9>"$state_directory/deploy.lock"
|
||||
if ! flock -n 9; then
|
||||
printf 'Another Yawn deployment is already running.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$repository"
|
||||
if [[ "$(git branch --show-current)" != "main" ]]; then
|
||||
printf 'Deployments must run from the main branch.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then
|
||||
printf 'Refusing to deploy a checkout with local changes.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin main
|
||||
remote_revision="$(git rev-parse FETCH_HEAD)"
|
||||
current_revision="$(git rev-parse HEAD)"
|
||||
deployed_revision=""
|
||||
if [[ -f "$deployed_revision_file" ]]; then
|
||||
read -r deployed_revision <"$deployed_revision_file"
|
||||
fi
|
||||
|
||||
container="$("${compose[@]}" ps -q yawn 2>/dev/null || true)"
|
||||
container_status="missing"
|
||||
if [[ -n "$container" ]]; then
|
||||
container_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ "$current_revision" == "$remote_revision" &&
|
||||
"$deployed_revision" == "$remote_revision" &&
|
||||
"$container_status" == "healthy" ]]; then
|
||||
printf 'Yawn is already running revision %s.\n' "${remote_revision:0:12}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'Deploying revision %s...\n' "${remote_revision:0:12}"
|
||||
flock -u 9
|
||||
exec 9>&-
|
||||
"$repository/server/deploy-docker.sh"
|
||||
@@ -0,0 +1,95 @@
|
||||
.docs-body { background: #fff; }
|
||||
.docs-header { position: sticky; top: 0; width: 100%; height: 66px; padding: 0 28px; border-bottom: 1px solid #dbe7f0; background: rgb(255 255 255 / 94%); backdrop-filter: blur(14px); }
|
||||
.docs-header .wordmark { font-size: 19px; }
|
||||
.docs-product { margin-left: -22px; padding-left: 18px; color: #8190a1; border-left: 1px solid #d7e3ec; font-size: 13px; font-weight: 600; }
|
||||
.docs-header .site-nav { flex: 0 0 auto; }
|
||||
.docs-search { display: flex; width: min(370px, 35vw); height: 36px; align-items: center; gap: 9px; margin: 0 auto; padding: 0 10px; color: #8496a9; border: 1px solid #dbe6ef; border-radius: 8px; background: #f8fbfd; }
|
||||
.docs-search input { min-width: 0; flex: 1; color: #243d57; border: 0; outline: 0; background: transparent; font-size: 12px; }
|
||||
.docs-search kbd { padding: 2px 6px; color: #93a3b4; border: 1px solid #d4e0e9; border-radius: 4px; background: white; font: 10px/1.4 ui-monospace, monospace; }
|
||||
.docs-layout { display: grid; grid-template-columns: 245px minmax(0, 790px) 180px; justify-content: center; gap: 58px; }
|
||||
.docs-sidebar { position: sticky; top: 66px; height: calc(100vh - 66px); overflow: auto; padding: 35px 20px 35px 29px; border-right: 1px solid #e2ebf2; scrollbar-width: thin; }
|
||||
.sidebar-group { display: grid; gap: 2px; margin-bottom: 27px; }
|
||||
.sidebar-group > strong, .docs-toc > strong { margin-bottom: 8px; color: #8a9aaa; font-size: 9px; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.sidebar-group a { padding: 7px 10px; color: #64768a; border-radius: 6px; font-size: 12px; text-decoration: none; }
|
||||
.sidebar-group a:hover { color: #1685cf; background: #f3f9fd; }
|
||||
.sidebar-group a.active { color: #087fc9; background: #eaf7ff; font-weight: 650; }
|
||||
.sidebar-playground { display: flex; align-items: center; gap: 11px; margin-top: 36px; padding: 13px; color: #155a87; border: 1px solid #cde5f5; border-radius: 9px; background: #f2faff; text-decoration: none; }
|
||||
.sidebar-playground > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; color: white; background: #1b8fe5; font-size: 8px; }
|
||||
.sidebar-playground div { display: grid; gap: 3px; }
|
||||
.sidebar-playground strong { font-size: 11px; }
|
||||
.sidebar-playground small { color: #7e99ad; font-size: 9px; }
|
||||
.docs-main { min-width: 0; padding: 72px 0 150px; }
|
||||
.docs-main article { min-width: 0; }
|
||||
.docs-main section { padding-top: 80px; scroll-margin-top: 55px; }
|
||||
.docs-main section:first-child { padding-top: 0; }
|
||||
.docs-main section + section { margin-top: 20px; border-top: 1px solid #e5edf3; }
|
||||
.doc-eyebrow { margin-bottom: 16px; color: #1788d2; font-size: 10px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.docs-main h1 { max-width: 700px; margin: 0 0 20px; color: #102a44; font-size: 48px; letter-spacing: -.055em; line-height: 1.08; }
|
||||
.docs-main h2 { margin: 14px 0 19px; color: #112c47; font-size: 31px; letter-spacing: -.04em; line-height: 1.2; }
|
||||
.docs-main h3 { margin: 32px 0 12px; color: #1b3751; font-size: 18px; letter-spacing: -.02em; }
|
||||
.docs-main p, .docs-main li { color: #5f7083; font-size: 14px; line-height: 1.78; }
|
||||
.docs-main p { margin: 0 0 19px; }
|
||||
.docs-main strong { color: #314d67; }
|
||||
.doc-lede { max-width: 730px; color: #50657a !important; font-size: 17px !important; line-height: 1.75 !important; }
|
||||
.docs-main :not(pre) > code { padding: 2px 5px; color: #147ab9; border: 1px solid #d7e9f4; border-radius: 4px; background: #f3faff; font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.section-kicker { display: flex; align-items: center; gap: 9px; margin-bottom: 13px; color: #1587d0; font-size: 10px; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
|
||||
.section-kicker span { display: grid; width: 23px; height: 23px; place-items: center; border: 1px solid #bde1f7; border-radius: 50%; background: #eef9ff; font: 700 8px/1 ui-monospace, monospace; }
|
||||
.callout { display: flex; gap: 14px; margin: 25px 0; padding: 18px 20px; border: 1px solid #e0e8ef; border-radius: 10px; background: #f9fbfd; }
|
||||
.callout-blue { border-color: #cce8f8; background: #f1faff; }
|
||||
.callout-icon { display: grid; width: 25px; height: 25px; flex: 0 0 auto; place-items: center; color: #1688d1; border: 1px solid #b9dff5; border-radius: 50%; font-size: 11px; font-weight: 800; }
|
||||
.callout strong { display: block; margin-bottom: 3px; font-size: 12px; }
|
||||
.callout p { margin: 0; font-size: 12px; line-height: 1.65; }
|
||||
.code-block { overflow: hidden; margin: 23px 0 26px; border: 1px solid #d4e2ec; border-radius: 10px; background: #0c2037; box-shadow: 0 8px 24px rgb(30 65 94 / 7%); }
|
||||
.code-title { display: flex; height: 38px; align-items: center; justify-content: space-between; padding: 0 12px 0 16px; color: #8299ae; border-bottom: 1px solid #233c54; background: #102840; font: 10px/1 ui-monospace, monospace; }
|
||||
.code-title button { padding: 5px 8px; color: #91a9bc; border: 1px solid #2b4861; border-radius: 5px; background: #163149; cursor: pointer; font-size: 9px; }
|
||||
.code-title button:hover { color: #bce7ff; border-color: #34729c; }
|
||||
.code-block pre { margin: 0; padding: 19px 21px 22px; overflow: auto; }
|
||||
.code-block code { color: #c8d8e6; font: 11.5px/1.75 ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; }
|
||||
.code-block-large code { font-size: 12px; }
|
||||
.explain-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 11px; margin: 24px 0; }
|
||||
.explain-grid > div { padding: 16px; border: 1px solid #e0e9f0; border-radius: 9px; background: #fbfdfe; }
|
||||
.explain-grid p { margin: 10px 0 0; font-size: 11px; line-height: 1.55; }
|
||||
.inline-play { display: flex; align-items: center; gap: 14px; margin: 27px 0; padding: 18px; color: #185e8c; border: 1px solid #c8e5f6; border-radius: 11px; background: #f0f9ff; text-decoration: none; }
|
||||
.inline-play > span { display: grid; width: 33px; height: 33px; place-items: center; border-radius: 50%; color: white; background: #198ddd; font-size: 10px; }
|
||||
.inline-play > div { display: grid; flex: 1; gap: 3px; }
|
||||
.inline-play strong { color: #185e8c; font-size: 12px; }
|
||||
.inline-play small { color: #7598af; font-size: 10px; }
|
||||
.inline-play b { font-size: 11px; }
|
||||
.mental-model { display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; align-items: center; gap: 11px; margin: 28px 0; }
|
||||
.mental-model > div { display: grid; min-height: 85px; align-content: center; gap: 7px; padding: 13px; border: 1px solid #d9e6ef; border-radius: 8px; background: #f9fcfe; text-align: center; }
|
||||
.mental-model strong { color: #31536e; font-size: 11px; }
|
||||
.mental-model span { color: #8a9aaa; font: 8px/1.5 ui-monospace, monospace; }
|
||||
.mental-model i { color: #8bbbd9; font-style: normal; }
|
||||
.mental-model .mental-shared { border-color: #a9daf5; background: #edf8ff; }
|
||||
.deploy-list { display: grid; gap: 0; margin: 20px 0 35px; padding: 0; list-style: none; }
|
||||
.deploy-list li { display: flex; gap: 16px; padding: 18px 0; border-bottom: 1px solid #e5edf3; }
|
||||
.deploy-list li > span { display: grid; width: 29px; height: 29px; flex: 0 0 auto; place-items: center; color: #1689d2; border: 1px solid #c3e2f5; border-radius: 50%; background: #f0f9ff; font: 700 10px/1 ui-monospace, monospace; }
|
||||
.deploy-list strong { display: block; margin-bottom: 3px; font-size: 13px; }
|
||||
.deploy-list p { margin: 0; font-size: 12px; }
|
||||
.next-card { display: flex; align-items: center; justify-content: space-between; gap: 35px; margin-top: 48px; padding: 30px; color: white; border-radius: 13px; background: linear-gradient(120deg, #0c2945, #0d5586); }
|
||||
.next-card small { color: #7dc8f2; font-size: 9px; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.next-card h3 { margin: 7px 0 4px; color: white; }
|
||||
.next-card p { margin: 0; color: #a8c7da; font-size: 11px; }
|
||||
.next-card .button { flex: 0 0 auto; }
|
||||
.docs-toc { position: sticky; top: 66px; height: fit-content; display: grid; gap: 9px; padding-top: 47px; }
|
||||
.docs-toc a { padding-left: 11px; color: #8795a5; border-left: 1px solid #dce6ed; font-size: 10px; line-height: 1.5; text-decoration: none; }
|
||||
.docs-toc a:hover, .docs-toc a.active { color: #1789d2; border-color: #1789d2; }
|
||||
.search-hidden { display: none !important; }
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.docs-layout { grid-template-columns: 235px minmax(0, 740px); gap: 40px; padding-right: 30px; }
|
||||
.docs-toc { display: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.docs-header { padding-inline: 15px; }
|
||||
.docs-product, .docs-header .site-nav { display: none; }
|
||||
.docs-search { width: auto; flex: 1; margin-left: auto; }
|
||||
.docs-layout { display: block; padding: 0 18px; }
|
||||
.docs-sidebar { display: none; }
|
||||
.docs-main { padding-top: 48px; }
|
||||
.docs-main h1 { font-size: 39px; }
|
||||
.docs-main h2 { font-size: 27px; }
|
||||
.explain-grid, .mental-model { grid-template-columns: 1fr; }
|
||||
.mental-model > i { transform: rotate(90deg); text-align: center; }
|
||||
.next-card { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const search = document.querySelector(".docs-search input");
|
||||
const sections = [...document.querySelectorAll(".docs-main section[data-title]")];
|
||||
const navigationLinks = [...document.querySelectorAll(".docs-sidebar a[href^='#']")];
|
||||
const tocLinks = [...document.querySelectorAll(".docs-toc a")];
|
||||
|
||||
for (const button of document.querySelectorAll("[data-copy]")) {
|
||||
button.addEventListener("click", async () => {
|
||||
const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? "";
|
||||
await navigator.clipboard.writeText(code);
|
||||
const original = button.textContent;
|
||||
button.textContent = "Copied";
|
||||
setTimeout(() => (button.textContent = original), 1200);
|
||||
});
|
||||
}
|
||||
|
||||
search?.addEventListener("input", () => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
for (const section of sections) {
|
||||
const matches = !query || section.textContent.toLowerCase().includes(query);
|
||||
section.classList.toggle("search-hidden", !matches);
|
||||
}
|
||||
});
|
||||
|
||||
addEventListener("keydown", (event) => {
|
||||
if (event.key === "/" && document.activeElement !== search) {
|
||||
event.preventDefault();
|
||||
search?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const activate = (id) => {
|
||||
for (const link of [...navigationLinks, ...tocLinks]) {
|
||||
link.classList.toggle("active", link.hash === `#${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.filter((entry) => entry.isIntersecting);
|
||||
if (visible.length) activate(visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0].target.id);
|
||||
},
|
||||
{ rootMargin: "-70px 0px -72%", threshold: 0 },
|
||||
);
|
||||
for (const section of sections) observer.observe(section);
|
||||
@@ -0,0 +1,163 @@
|
||||
:root {
|
||||
color: #17324c;
|
||||
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
button, input { font: inherit; }
|
||||
button { color: inherit; }
|
||||
.playground-app { display: grid; width: 100%; height: 100%; height: 100dvh; grid-template-rows: 58px 41px minmax(0, 1fr); background: #f7fbfe; }
|
||||
|
||||
.playground-header { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 0 16px 0 19px; border-bottom: 1px solid #d5e3ed; background: #fff; }
|
||||
.playground-brand { display: inline-flex; align-items: center; gap: 9px; color: #102d49; text-decoration: none; }
|
||||
.playground-brand strong { font-size: 18px; letter-spacing: -.04em; }
|
||||
.brand-mark { position: relative; display: inline-block; width: 22px; height: 22px; }
|
||||
.brand-mark i { position: absolute; width: 7px; border: 2px solid #218ddd; border-radius: 7px; transform: rotate(-28deg); }
|
||||
.brand-mark i:nth-child(1) { left: 1px; top: 9px; height: 10px; }
|
||||
.brand-mark i:nth-child(2) { left: 7px; top: 5px; height: 14px; }
|
||||
.brand-mark i:nth-child(3) { left: 14px; top: 1px; height: 18px; }
|
||||
.header-divider { width: 1px; height: 22px; margin-inline: 2px; background: #d8e3eb; }
|
||||
.project-name { display: flex; min-width: 0; align-items: center; gap: 7px; }
|
||||
.project-name input { width: min(250px, 24vw); padding: 5px 7px; color: #29445e; border: 1px solid transparent; border-radius: 5px; outline: 0; background: transparent; font-size: 13px; font-weight: 650; text-overflow: ellipsis; }
|
||||
.project-name input:hover { background: #f5f9fc; }
|
||||
.project-name input:focus { border-color: #a9d8f5; background: #f7fcff; box-shadow: 0 0 0 2px rgb(50 166 232 / 10%); }
|
||||
#dirty-indicator, #tab-dirty { display: none; width: 6px; height: 6px; border-radius: 50%; background: #2a9ee6; }
|
||||
#dirty-indicator.visible, #tab-dirty.visible { display: inline-block; }
|
||||
#revision-label { padding: 4px 7px; color: #74879a; border: 1px solid #dae5ed; border-radius: 4px; background: #f8fafc; font: 600 9px/1 ui-monospace, monospace; }
|
||||
.playground-nav { display: flex; flex: 1; justify-content: flex-end; gap: 21px; }
|
||||
.playground-nav a { color: #687a8c; font-size: 11px; font-weight: 600; text-decoration: none; }
|
||||
.playground-nav a:hover { color: #1889d2; }
|
||||
.tool-button, .run-button { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 7px; padding: 0 12px; border-radius: 6px; cursor: pointer; font-size: 11px; font-weight: 650; }
|
||||
.tool-button { color: #486177; border: 1px solid #d4e1ea; background: #fff; }
|
||||
.tool-button:hover { color: #177fc2; border-color: #add5ee; background: #f6fbfe; }
|
||||
.run-button { min-width: 111px; color: white; border: 1px solid #1687ce; background: #1a8fda; box-shadow: 0 4px 12px rgb(26 143 218 / 18%); }
|
||||
.run-button:hover { background: #117fc5; }
|
||||
.run-button:disabled, .tool-button:disabled { cursor: wait; opacity: .58; }
|
||||
.run-button kbd { padding-left: 7px; color: rgb(255 255 255 / 70%); border: 0; border-left: 1px solid rgb(255 255 255 / 24%); font: 8px/1 ui-monospace, monospace; }
|
||||
|
||||
.playground-tools { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 0 10px; border-bottom: 1px solid #cfdce6; background: #f6f9fb; }
|
||||
.tool-group { display: flex; align-items: center; gap: 2px; }
|
||||
.tool-group button { display: inline-flex; height: 29px; align-items: center; gap: 6px; padding: 0 9px; color: #586d80; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; font-size: 10px; }
|
||||
.tool-group button:hover, .tool-group button[aria-pressed="true"] { color: #187fc2; border-color: #d3e3ed; background: #fff; }
|
||||
.braces { color: #2498df; font: 700 10px/1 ui-monospace, monospace; }
|
||||
.tool-context { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: center; gap: 7px; color: #748698; font-size: 9px; }
|
||||
.language-dot { width: 6px; height: 6px; border-radius: 2px; background: #2d9de3; }
|
||||
.context-divider { width: 1px; height: 12px; margin-inline: 4px; background: #cfdae3; }
|
||||
#type-status { overflow: hidden; max-width: 250px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tool-group-right { margin-left: auto; }
|
||||
|
||||
.workbench { --editor-width: 50%; display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(330px, var(--editor-width)) 5px minmax(330px, 1fr); background: #0b1725; }
|
||||
.editor-pane, .stage-pane { min-width: 0; min-height: 0; }
|
||||
.editor-pane { display: grid; grid-template-rows: 37px minmax(0, 1fr) 24px; }
|
||||
.pane-tabs, .stage-toolbar { display: flex; min-width: 0; align-items: stretch; border-bottom: 1px solid #1d3245; background: #112233; }
|
||||
.pane-tab { display: flex; min-width: 145px; align-items: center; gap: 7px; padding: 0 14px; color: #9db1c2; border: 0; border-right: 1px solid #22384b; background: #0d1d2d; font-size: 10px; }
|
||||
.pane-tab.active { color: #d4e2ed; border-top: 2px solid #2ba5ea; background: #0b1927; }
|
||||
.ts-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #fff; border-radius: 2px; background: #278dcd; font: 700 7px/1 ui-monospace, monospace; }
|
||||
.editor { position: relative; min-height: 0; overflow: hidden; background: #0b1927; }
|
||||
.editor-loading { display: grid; height: 100%; place-items: center; color: #668097; font: 10px/1 ui-monospace, monospace; }
|
||||
.monaco-editor .margin { background-color: #0b1927 !important; }
|
||||
.editor-status { display: flex; align-items: center; gap: 14px; padding: 0 10px; color: #6f8599; border-top: 1px solid #1b3042; background: #0c1d2c; font: 8px/1 ui-monospace, monospace; }
|
||||
.status-spacer { flex: 1; }
|
||||
#editor-diagnostics { display: flex; align-items: center; gap: 5px; }
|
||||
#editor-diagnostics i { width: 5px; height: 5px; border-radius: 50%; background: #4aa975; }
|
||||
#editor-diagnostics.has-errors { color: #e8a4a4; }
|
||||
#editor-diagnostics.has-errors i { background: #e26f6f; }
|
||||
.resize-handle { position: relative; z-index: 3; cursor: col-resize; background: #172a3b; }
|
||||
.resize-handle::after { position: absolute; top: 50%; left: 1px; width: 3px; height: 40px; transform: translateY(-50%); border-radius: 3px; background: #355067; content: ""; opacity: .65; }
|
||||
.resize-handle:hover, .resize-handle.dragging { background: #218ed2; }
|
||||
|
||||
.stage-pane { display: grid; grid-template-rows: 37px minmax(230px, 1fr) 190px; background: #091521; }
|
||||
.stage-toolbar { justify-content: space-between; }
|
||||
.stage-tabs button { height: 100%; padding: 0 14px; color: #8da3b6; border: 0; border-top: 2px solid transparent; background: transparent; font-size: 10px; }
|
||||
.stage-tabs button.active { color: #d4e3ee; border-top-color: #35a9ed; background: #0d1d2c; }
|
||||
.stage-meta { display: flex; min-width: 0; align-items: center; gap: 10px; padding-right: 12px; color: #6e869a; font: 8px/1 ui-monospace, monospace; }
|
||||
#run-status { display: flex; align-items: center; gap: 6px; }
|
||||
#run-status i { width: 6px; height: 6px; border-radius: 50%; background: #4cad79; box-shadow: 0 0 0 3px rgb(76 173 121 / 10%); }
|
||||
#run-status.running i { background: #36a8e8; animation: status-pulse 1s infinite; }
|
||||
#run-status.failed { color: #e49a9a; }
|
||||
#run-status.failed i { background: #e36d6d; }
|
||||
.stage-divider { width: 1px; height: 12px; background: #294052; }
|
||||
.preview { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 50% 45%, #102b43, #06111d 72%); }
|
||||
.preview::before { position: absolute; inset: 0; background-image: linear-gradient(rgb(82 143 182 / 5%) 1px, transparent 1px), linear-gradient(90deg, rgb(82 143 182 / 5%) 1px, transparent 1px); background-size: 32px 32px; content: ""; pointer-events: none; }
|
||||
.preview canvas { position: relative; display: block; width: 100%; height: 100%; object-fit: contain; touch-action: none; }
|
||||
.preview-message { position: absolute; top: 50%; left: 50%; display: grid; justify-items: center; gap: 7px; transform: translate(-50%, -50%); color: #93aabd; text-align: center; pointer-events: none; }
|
||||
.preview-message[hidden] { display: none; }
|
||||
.preview-message-icon { display: grid; width: 43px; height: 43px; margin-bottom: 7px; place-items: center; color: #54bdf6; border: 1px solid #25577a; border-radius: 12px; background: #0b2941; box-shadow: 0 0 30px rgb(46 171 238 / 12%); font-size: 16px; font-weight: 800; }
|
||||
.preview-message strong { color: #b9cddd; font-size: 11px; }
|
||||
.preview-message small { color: #627a8f; font-size: 9px; }
|
||||
|
||||
.inspector { min-height: 0; border-top: 1px solid #22384b; background: #0a1825; }
|
||||
.inspector.collapsed { display: none; }
|
||||
.stage-pane:has(.inspector.collapsed) { grid-template-rows: 37px minmax(230px, 1fr) 0; }
|
||||
.inspector-tabs { display: flex; height: 31px; align-items: stretch; border-bottom: 1px solid #1c3041; background: #0e1e2c; }
|
||||
.inspector-tabs button { padding: 0 13px; color: #71889b; border: 0; border-bottom: 1px solid transparent; background: transparent; cursor: pointer; font-size: 9px; }
|
||||
.inspector-tabs button.active { color: #c0d2df; border-bottom-color: #32a8eb; }
|
||||
.inspector-tabs button span { margin-left: 4px; padding: 1px 5px; border-radius: 5px; background: #1a3245; font-size: 7px; }
|
||||
.inspector-tabs .clear-console { margin-left: auto; color: #61788b; }
|
||||
.inspector-panel { display: none; height: calc(100% - 31px); overflow: auto; }
|
||||
.inspector-panel.active { display: block; }
|
||||
.console-empty, .profile-empty { display: flex; height: 100%; align-items: center; justify-content: center; gap: 10px; color: #718a9f; font: 9px/1 ui-monospace, monospace; }
|
||||
.console-empty > span { color: #318fc7; font-size: 12px; }
|
||||
.console-empty code { color: #90aabd; }
|
||||
#console-lines { margin: 0; padding: 5px 0; color: #9cb1c1; list-style: none; font: 9px/1.6 ui-monospace, monospace; }
|
||||
#console-lines li { display: flex; min-height: 22px; align-items: flex-start; gap: 9px; padding: 3px 10px; border-bottom: 1px solid rgb(36 57 73 / 45%); white-space: pre-wrap; word-break: break-word; }
|
||||
#console-lines li::before { color: #399ed9; content: "›"; }
|
||||
#console-lines li.error { color: #e19a9a; }
|
||||
#console-lines li.error::before { color: #dd6b6b; content: "×"; }
|
||||
.profile-empty { font-family: inherit; }
|
||||
.profile-empty .pulse-icon { display: grid; width: 30px; height: 30px; place-items: center; color: #48ace4; border: 1px solid #24516f; border-radius: 50%; background: #0e2940; }
|
||||
.profile-empty > div { display: grid; gap: 4px; }
|
||||
.profile-empty strong { color: #91a9ba; font-size: 10px; }
|
||||
.profile-empty small { color: #526b7e; font-size: 8px; }
|
||||
.profile-results { padding: 10px 12px; color: #8fa6b8; }
|
||||
.profile-summary { display: grid; grid-template-columns: .7fr .8fr 1.5fr; gap: 8px; margin-bottom: 9px; }
|
||||
.profile-summary > div { display: grid; gap: 3px; padding: 7px 9px; border: 1px solid #1e384b; border-radius: 5px; background: #0e2130; }
|
||||
.profile-summary small { color: #587185; font-size: 7px; text-transform: uppercase; }
|
||||
.profile-summary strong { overflow: hidden; color: #9fc7de; font: 8px/1.3 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.profile-results table { width: 100%; border-collapse: collapse; font: 8px/1.4 ui-monospace, monospace; }
|
||||
.profile-results th, .profile-results td { padding: 5px 7px; border-bottom: 1px solid #1b3041; text-align: left; }
|
||||
.profile-results th { color: #506b80; font-weight: 600; }
|
||||
.profile-results td:last-child, .profile-results th:last-child { color: #56b4e8; text-align: right; }
|
||||
|
||||
.snippets-dialog { width: min(580px, calc(100% - 30px)); padding: 0; color: #18354f; border: 1px solid #cadce8; border-radius: 14px; background: #fff; box-shadow: 0 30px 90px rgb(5 25 42 / 28%); }
|
||||
.snippets-dialog::backdrop { background: rgb(6 23 38 / 48%); backdrop-filter: blur(3px); }
|
||||
.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; padding: 25px 26px 17px; }
|
||||
.dialog-heading span { color: #1b8ed8; font-size: 8px; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.dialog-heading h2 { margin: 5px 0 3px; font-size: 23px; letter-spacing: -.04em; }
|
||||
.dialog-heading p { margin: 0; color: #798b9c; font-size: 10px; }
|
||||
.dialog-heading > button { color: #7c8fa0; border: 0; background: transparent; cursor: pointer; font-size: 22px; }
|
||||
.snippet-search { display: flex; height: 35px; align-items: center; gap: 8px; margin: 0 26px 14px; padding: 0 10px; color: #8a9aaa; border: 1px solid #d8e5ed; border-radius: 7px; background: #f9fbfd; }
|
||||
.snippet-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 10px; }
|
||||
.snippet-grid { display: grid; gap: 1px; padding: 0 16px 18px; }
|
||||
.snippet-grid > button { display: grid; min-height: 66px; grid-template-columns: 40px 1fr auto; align-items: center; gap: 12px; padding: 8px 10px; text-align: left; border: 1px solid transparent; border-radius: 8px; background: transparent; cursor: pointer; }
|
||||
.snippet-grid > button:hover { border-color: #cee5f4; background: #f3faff; }
|
||||
.snippet-icon { display: grid; width: 36px; height: 36px; place-items: center; color: #168bd3; border: 1px solid #c6e4f6; border-radius: 9px; background: #edf9ff; font-size: 14px; }
|
||||
.snippet-grid button > div { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 3px 8px; }
|
||||
.snippet-grid strong { color: #294861; font-size: 11px; }
|
||||
.snippet-grid small { grid-column: 1; color: #7b8e9f; font-size: 9px; }
|
||||
.snippet-grid code { grid-column: 2; grid-row: 1 / 3; justify-self: end; padding: 4px 6px; color: #3b82ac; border: 1px solid #d8e8f1; border-radius: 4px; background: #f9fcfe; font: 8px/1 ui-monospace, monospace; }
|
||||
.snippet-grid b { color: #329cdc; font-size: 16px; }
|
||||
.toast { position: fixed; z-index: 100; right: 18px; bottom: 18px; max-width: 360px; padding: 11px 15px; transform: translateY(20px); color: #d8e9f4; border: 1px solid #2d5875; border-radius: 7px; background: #102d42; box-shadow: 0 12px 35px rgb(2 17 29 / 24%); font-size: 10px; opacity: 0; pointer-events: none; transition: opacity 180ms ease, transform 180ms ease; }
|
||||
.toast.visible { transform: none; opacity: 1; }
|
||||
@keyframes status-pulse { 50% { opacity: .35; } }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.playground-header { gap: 8px; padding-inline: 10px; }
|
||||
.header-divider, .playground-nav, #share-button, .project-name #revision-label { display: none; }
|
||||
.project-name { min-width: 0; flex: 1; }
|
||||
.project-name input { width: 100%; }
|
||||
.tool-button { padding-inline: 8px; }
|
||||
.run-button { min-width: auto; }
|
||||
.run-button kbd { display: none; }
|
||||
.tool-context { display: none; }
|
||||
.tool-group-right { margin-left: auto; }
|
||||
.workbench { display: grid; overflow: auto; grid-template-columns: 1fr; grid-template-rows: minmax(400px, 55vh) minmax(450px, 1fr); }
|
||||
.resize-handle { display: none; }
|
||||
.stage-pane { min-height: 500px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
const defaultSource = `import {
|
||||
AmbientLight,
|
||||
ArcRotateCamera,
|
||||
ColorGrading,
|
||||
FXAA,
|
||||
Icosphere,
|
||||
PBRMaterial,
|
||||
Plane,
|
||||
PointLight,
|
||||
Scene,
|
||||
SpotLight,
|
||||
} from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true, fps: 60 });
|
||||
await scene.ready;
|
||||
await scene.reserve({ nodes: 6, materials: 2 });
|
||||
|
||||
const objectMaterial = new PBRMaterial(scene, {
|
||||
baseColor: [0.82, 0.86, 0.9, 1],
|
||||
metallic: 0.12,
|
||||
roughness: 0.32,
|
||||
});
|
||||
const groundMaterial = new PBRMaterial(scene, {
|
||||
baseColor: [0.24, 0.28, 0.34, 1],
|
||||
metallic: 0,
|
||||
roughness: 0.82,
|
||||
});
|
||||
await Promise.all([objectMaterial.ready, groundMaterial.ready]);
|
||||
|
||||
const icosphere = new Icosphere(scene, {
|
||||
material: objectMaterial,
|
||||
subdivisions: 2,
|
||||
position: [0, 0.2, 0],
|
||||
});
|
||||
const ground = new Plane(scene, {
|
||||
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 {
|
||||
scene,
|
||||
icosphere,
|
||||
ground,
|
||||
lights,
|
||||
dispose() {
|
||||
camera.detachControls();
|
||||
scene.dispose();
|
||||
},
|
||||
};`;
|
||||
|
||||
const snippets = {
|
||||
camera: `// Orbit camera — drag to orbit and use the wheel to zoom.
|
||||
const orbitCamera = new ArcRotateCamera(scene, {
|
||||
target: icosphere,
|
||||
alpha: 0,
|
||||
beta: Math.PI / 2,
|
||||
radius: 3,
|
||||
controls: { element: canvas, pointer: true },
|
||||
});
|
||||
await orbitCamera.ready;`,
|
||||
light: `// A warm point light backed by shared rows.
|
||||
const light = new PointLight(scene, {
|
||||
position: [0, 0.7, 1],
|
||||
color: [1, 0.68, 0.4],
|
||||
intensity: 12,
|
||||
range: 8,
|
||||
});
|
||||
await light.ready;`,
|
||||
instances: `// Clones share geometry until one clone changes its vertex data.
|
||||
for (let x = -2; x <= 2; x++) {
|
||||
const instance = icosphere.clone({ position: [x * 0.7, 0.2, 0] });
|
||||
await instance.ready;
|
||||
}`,
|
||||
rows: `// Application-owned hot data in the same shared arena.
|
||||
const velocity = await scene.ensureRows("app.velocity", 1024, 16, "f32");
|
||||
velocity.row(0).set([1, 0, 0, 0]);
|
||||
log("velocity[0]", velocity.read(0));`,
|
||||
post: `// Batch graph changes so the loadout is rebuilt once.
|
||||
await scene.batchGraphUpdates(async () => {
|
||||
const grade = new ColorGrading(scene, { toneMap: "aces", amount: 1 });
|
||||
const fxaa = new FXAA(scene);
|
||||
await Promise.all([grade.ready, fxaa.ready]);
|
||||
});`,
|
||||
};
|
||||
|
||||
const elements = Object.fromEntries(
|
||||
[
|
||||
"canvas",
|
||||
"clear-console",
|
||||
"console-count",
|
||||
"console-empty",
|
||||
"console-lines",
|
||||
"cursor-status",
|
||||
"dirty-indicator",
|
||||
"editor",
|
||||
"editor-diagnostics",
|
||||
"format-button",
|
||||
"fps-status",
|
||||
"fullscreen-button",
|
||||
"inspector",
|
||||
"inspector-button",
|
||||
"preview",
|
||||
"preview-message",
|
||||
"profile-adapter",
|
||||
"profile-empty",
|
||||
"profile-latency",
|
||||
"profile-passes",
|
||||
"profile-results",
|
||||
"profile-total",
|
||||
"project-title",
|
||||
"reset-button",
|
||||
"resize-handle",
|
||||
"resolution-status",
|
||||
"revision-label",
|
||||
"run-button",
|
||||
"run-status",
|
||||
"save-button",
|
||||
"share-button",
|
||||
"snippets-button",
|
||||
"snippets-dialog",
|
||||
"tab-dirty",
|
||||
"toast",
|
||||
"type-status",
|
||||
"workbench",
|
||||
].map((id) => [id, document.getElementById(id)]),
|
||||
);
|
||||
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
const nativeSetTimeout = window.setTimeout.bind(window);
|
||||
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 editor;
|
||||
let model;
|
||||
let current;
|
||||
let currentID = "";
|
||||
let currentRevision = 0;
|
||||
let initialSource = defaultSource;
|
||||
let generation = 0;
|
||||
let dirty = false;
|
||||
let activePanel = "console";
|
||||
let stopProfile;
|
||||
let profileCore;
|
||||
let toastTimer;
|
||||
let frameRequest;
|
||||
let sampledFrame = 0;
|
||||
let sampledAt = 0;
|
||||
|
||||
addEventListener("yawn-theme-change", (event) => {
|
||||
monaco?.editor.setTheme(event.detail.resolved === "dark" ? "yawn" : "vs");
|
||||
});
|
||||
|
||||
function savedLocation() {
|
||||
const match = location.pathname.match(
|
||||
/^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/,
|
||||
);
|
||||
return match ? { id: match[1], revision: Number(match[2]) } : undefined;
|
||||
}
|
||||
|
||||
async function loadInitialPlayground() {
|
||||
const saved = savedLocation();
|
||||
if (saved) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/playgrounds/${saved.id}/${saved.revision}`,
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error("This saved playground could not be found.");
|
||||
const result = await response.json();
|
||||
currentID = result.id;
|
||||
currentRevision = result.revision;
|
||||
elements["project-title"].value = result.title;
|
||||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||||
elements["share-button"].hidden = false;
|
||||
initialSource = result.code;
|
||||
document.title = `${result.title} — Yawn Playground`;
|
||||
return result.code;
|
||||
} catch (error) {
|
||||
history.replaceState(null, "", "/playground");
|
||||
setRunStatus(error.message, "failed");
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
return localStorage.getItem("yawn:playground:draft") || defaultSource;
|
||||
}
|
||||
|
||||
function loadMonaco() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.require?.config) {
|
||||
reject(new Error("The TypeScript editor could not be loaded."));
|
||||
return;
|
||||
}
|
||||
const monacoBase = `${window.location.origin}/assets/monaco/`;
|
||||
const workerSource = `
|
||||
self.MonacoEnvironment = { baseUrl: ${JSON.stringify(monacoBase)} };
|
||||
importScripts(${JSON.stringify(`${monacoBase}vs/base/worker/workerMain.js`)});
|
||||
`;
|
||||
const workerURL = URL.createObjectURL(
|
||||
new Blob([workerSource], { type: "text/javascript" }),
|
||||
);
|
||||
window.MonacoEnvironment = { getWorkerUrl: () => workerURL };
|
||||
window.require.config({ paths: { vs: `${monacoBase}vs` } });
|
||||
window.require(
|
||||
["vs/editor/editor.main"],
|
||||
() => resolve(window.monaco),
|
||||
reject,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function createEditor(source) {
|
||||
monaco = await loadMonaco();
|
||||
const types = await fetch("/assets/yawn.d.ts").then((response) =>
|
||||
response.text(),
|
||||
);
|
||||
const defaults = monaco.languages.typescript.typescriptDefaults;
|
||||
defaults.setEagerModelSync(true);
|
||||
defaults.setCompilerOptions({
|
||||
allowNonTsExtensions: true,
|
||||
module: monaco.languages.typescript.ModuleKind.ESNext,
|
||||
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
|
||||
noEmit: false,
|
||||
strict: true,
|
||||
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
||||
});
|
||||
defaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
});
|
||||
defaults.addExtraLib(types, "file:///types/yawn.d.ts");
|
||||
monaco.editor.defineTheme("yawn", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "comment", foreground: "658198", fontStyle: "italic" },
|
||||
{ token: "keyword", foreground: "C792EA" },
|
||||
{ token: "string", foreground: "8FD5B6" },
|
||||
{ token: "number", foreground: "F6AD7B" },
|
||||
{ token: "type.identifier", foreground: "69C7F4" },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#0B1927",
|
||||
"editor.foreground": "#C8D8E5",
|
||||
"editor.lineHighlightBackground": "#102235",
|
||||
"editor.selectionBackground": "#1D5B8066",
|
||||
"editorCursor.foreground": "#55BDF4",
|
||||
"editorGutter.background": "#0B1927",
|
||||
"editorLineNumber.foreground": "#405B70",
|
||||
"editorLineNumber.activeForeground": "#89A4B8",
|
||||
"editorIndentGuide.background1": "#1B3143",
|
||||
"editorWidget.background": "#102538",
|
||||
"editorSuggestWidget.background": "#102538",
|
||||
},
|
||||
});
|
||||
elements.editor.textContent = "";
|
||||
model = monaco.editor.createModel(
|
||||
source,
|
||||
"typescript",
|
||||
monaco.Uri.parse("file:///playground/scene.ts"),
|
||||
);
|
||||
editor = monaco.editor.create(elements.editor, {
|
||||
model,
|
||||
theme: document.documentElement.dataset.theme === "dark" ? "yawn" : "vs",
|
||||
automaticLayout: true,
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
fontLigatures: true,
|
||||
fontSize: 12,
|
||||
lineHeight: 20,
|
||||
minimap: { enabled: false },
|
||||
padding: { top: 12, bottom: 12 },
|
||||
renderLineHighlight: "all",
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
tabSize: 2,
|
||||
wordWrap: "off",
|
||||
});
|
||||
editor.addAction({
|
||||
id: "yawn.run",
|
||||
label: "Run playground",
|
||||
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
|
||||
run,
|
||||
});
|
||||
editor.onDidChangeCursorPosition(({ position }) => {
|
||||
elements["cursor-status"].textContent =
|
||||
`Ln ${position.lineNumber}, Col ${position.column}`;
|
||||
});
|
||||
model.onDidChangeContent(() => {
|
||||
markDirty(true);
|
||||
if (!currentID)
|
||||
localStorage.setItem("yawn:playground:draft", model.getValue());
|
||||
});
|
||||
monaco.editor.onDidChangeMarkers(([resource]) => {
|
||||
if (resource.toString() === model.uri.toString()) updateDiagnostics();
|
||||
});
|
||||
elements["type-status"].textContent = "TypeScript language service ready";
|
||||
updateDiagnostics();
|
||||
}
|
||||
|
||||
function updateDiagnostics() {
|
||||
if (!monaco || !model) return;
|
||||
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||||
const errors = markers.filter(
|
||||
(marker) => marker.severity === monaco.MarkerSeverity.Error,
|
||||
).length;
|
||||
const warnings = markers.filter(
|
||||
(marker) => marker.severity === monaco.MarkerSeverity.Warning,
|
||||
).length;
|
||||
const total = errors + warnings;
|
||||
elements["editor-diagnostics"].classList.toggle("has-errors", errors > 0);
|
||||
elements["editor-diagnostics"].lastChild.textContent = total
|
||||
? ` ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`
|
||||
: " No problems";
|
||||
elements["type-status"].textContent = errors
|
||||
? `${errors} TypeScript error${errors === 1 ? "" : "s"}`
|
||||
: "TypeScript language service ready";
|
||||
}
|
||||
|
||||
function markDirty(value) {
|
||||
dirty = value;
|
||||
elements["dirty-indicator"].classList.toggle("visible", value);
|
||||
elements["tab-dirty"].classList.toggle("visible", value);
|
||||
}
|
||||
|
||||
async function transpile() {
|
||||
const worker = await monaco.languages.typescript.getTypeScriptWorker();
|
||||
const client = await worker(model.uri);
|
||||
const emitted = await client.getEmitOutput(model.uri.toString());
|
||||
const output = emitted.outputFiles.find((file) => file.name.endsWith(".js"));
|
||||
if (emitted.emitSkipped || !output)
|
||||
throw new Error("TypeScript could not emit this scene.");
|
||||
return output.text
|
||||
.replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "")
|
||||
.replace(/^\s*export\s*\{\s*\};?\s*$/gm, "")
|
||||
.replace(/\bexport\s+default\s+/, "return ");
|
||||
}
|
||||
|
||||
async function disposeCurrent() {
|
||||
stopProfile?.();
|
||||
stopProfile = undefined;
|
||||
if (profileCore) {
|
||||
try {
|
||||
await profileCore.setProfiler(false);
|
||||
} catch {
|
||||
// A disposed core cannot disable a profiler that is already gone.
|
||||
}
|
||||
profileCore = undefined;
|
||||
}
|
||||
const value = current;
|
||||
current = undefined;
|
||||
delete window.__yawnPlayground;
|
||||
if (!value) return;
|
||||
if (typeof value === "function") await value();
|
||||
else if (typeof value.dispose === "function") await value.dispose();
|
||||
else if (value.scene?.dispose) value.scene.dispose();
|
||||
}
|
||||
|
||||
function replaceCanvas() {
|
||||
const previous = elements.canvas;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = "canvas";
|
||||
canvas.setAttribute("aria-label", "Yawn WebGPU preview");
|
||||
const scale = Math.min(devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.max(1, Math.round(elements.preview.clientWidth * scale));
|
||||
canvas.height = Math.max(
|
||||
1,
|
||||
Math.round(elements.preview.clientHeight * scale),
|
||||
);
|
||||
previous.replaceWith(canvas);
|
||||
elements.canvas = canvas;
|
||||
elements["resolution-status"].textContent =
|
||||
`${canvas.width} × ${canvas.height}`;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!editor || elements["run-button"].disabled) return;
|
||||
clearPlaygroundTimers();
|
||||
const runID = ++generation;
|
||||
elements["run-button"].disabled = true;
|
||||
elements["run-button"].lastChild.textContent = " Running";
|
||||
clearConsole();
|
||||
setRunStatus("Compiling", "running");
|
||||
showPreviewMessage(
|
||||
"Compiling TypeScript",
|
||||
"Checking types and preparing the render graph.",
|
||||
);
|
||||
try {
|
||||
if (!crossOriginIsolated)
|
||||
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]);
|
||||
await disposeCurrent();
|
||||
const canvas = replaceCanvas();
|
||||
const names = Object.keys(api);
|
||||
const started = performance.now();
|
||||
const result = await new AsyncFunction(
|
||||
...names,
|
||||
"canvas",
|
||||
"log",
|
||||
"setTimeout",
|
||||
"setInterval",
|
||||
"clearTimeout",
|
||||
"clearInterval",
|
||||
code,
|
||||
)(
|
||||
...names.map((name) => api[name]),
|
||||
canvas,
|
||||
(...values) => appendConsole(values.map(formatValue).join(" ")),
|
||||
trackedSetTimeout,
|
||||
trackedSetInterval,
|
||||
trackedClearTimer,
|
||||
trackedClearTimer,
|
||||
);
|
||||
if (runID !== generation) {
|
||||
if (result?.dispose) await result.dispose();
|
||||
return;
|
||||
}
|
||||
current = result;
|
||||
window.__yawnPlayground = result;
|
||||
elements["preview-message"].hidden = true;
|
||||
setRunStatus(
|
||||
`Running · ${Math.round(performance.now() - started)} ms`,
|
||||
"ready",
|
||||
);
|
||||
if (activePanel === "profile") await attachProfiler();
|
||||
} catch (error) {
|
||||
if (runID === generation) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
appendConsole(message, true);
|
||||
setRunStatus("Run failed", "failed");
|
||||
showPreviewMessage("Scene could not start", message);
|
||||
openInspector("console");
|
||||
}
|
||||
} finally {
|
||||
if (runID === generation) {
|
||||
elements["run-button"].disabled = false;
|
||||
elements["run-button"].innerHTML =
|
||||
'<span aria-hidden="true">▶</span> Run <kbd>Ctrl ↵</kbd>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function currentCore() {
|
||||
return current?.scene?.core ?? current?.core;
|
||||
}
|
||||
|
||||
async function attachProfiler() {
|
||||
stopProfile?.();
|
||||
stopProfile = undefined;
|
||||
profileCore = currentCore();
|
||||
if (!profileCore?.onProfile || !profileCore?.setProfiler) {
|
||||
setProfileMessage("Run a scene to begin profiling.");
|
||||
return;
|
||||
}
|
||||
stopProfile = profileCore.onProfile(renderProfile);
|
||||
const supported = await profileCore.setProfiler(true);
|
||||
if (!supported)
|
||||
setProfileMessage(
|
||||
"Timestamp queries are not supported by this GPU adapter.",
|
||||
);
|
||||
}
|
||||
|
||||
function setProfileMessage(message) {
|
||||
elements["profile-empty"].querySelector("strong").textContent = message;
|
||||
elements["profile-empty"].hidden = false;
|
||||
elements["profile-results"].hidden = true;
|
||||
}
|
||||
|
||||
function renderProfile(profile) {
|
||||
elements["profile-empty"].hidden = true;
|
||||
elements["profile-results"].hidden = false;
|
||||
elements["profile-total"].textContent =
|
||||
`${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"].title = profile.adapter || "WebGPU adapter";
|
||||
elements["profile-passes"].replaceChildren(
|
||||
...profile.passes.map((pass) => {
|
||||
const row = document.createElement("tr");
|
||||
const name = document.createElement("td");
|
||||
const time = document.createElement("td");
|
||||
name.textContent = pass.name;
|
||||
time.textContent = `${pass.milliseconds.toFixed(2)} ms`;
|
||||
row.append(name, time);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof Error) return value.message;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function appendConsole(message, error = false) {
|
||||
elements["console-empty"].hidden = true;
|
||||
const line = document.createElement("li");
|
||||
line.textContent = message;
|
||||
line.classList.toggle("error", error);
|
||||
elements["console-lines"].append(line);
|
||||
elements["console-count"].textContent =
|
||||
elements["console-lines"].children.length;
|
||||
line.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function clearConsole() {
|
||||
elements["console-lines"].replaceChildren();
|
||||
elements["console-empty"].hidden = false;
|
||||
elements["console-count"].textContent = "0";
|
||||
}
|
||||
|
||||
function setRunStatus(message, state) {
|
||||
elements["run-status"].className =
|
||||
state === "running" ? "running" : state === "failed" ? "failed" : "";
|
||||
elements["run-status"].lastChild.textContent = ` ${message}`;
|
||||
}
|
||||
|
||||
function showPreviewMessage(title, detail) {
|
||||
elements["preview-message"].hidden = false;
|
||||
elements["preview-message"].querySelector("strong").textContent = title;
|
||||
elements["preview-message"].querySelector("small").textContent = detail;
|
||||
}
|
||||
|
||||
function openInspector(panel) {
|
||||
activePanel = panel;
|
||||
elements.inspector.classList.remove("collapsed");
|
||||
elements["inspector-button"].setAttribute("aria-pressed", "true");
|
||||
for (const button of document.querySelectorAll(
|
||||
".inspector-tabs [data-panel]",
|
||||
)) {
|
||||
const active = button.dataset.panel === panel;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
}
|
||||
for (const target of document.querySelectorAll(".inspector-panel")) {
|
||||
target.classList.toggle("active", target.id === `${panel}-panel`);
|
||||
}
|
||||
if (panel === "profile") void attachProfiler();
|
||||
else if (profileCore) {
|
||||
stopProfile?.();
|
||||
stopProfile = undefined;
|
||||
void profileCore.setProfiler(false).catch(() => undefined);
|
||||
profileCore = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editor || elements["save-button"].disabled) return;
|
||||
elements["save-button"].disabled = true;
|
||||
try {
|
||||
const response = await fetch("/api/playgrounds", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: currentID,
|
||||
title: elements["project-title"].value.trim() || "Untitled playground",
|
||||
code: model.getValue(),
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(result.error || "The playground could not be saved.");
|
||||
currentID = result.id;
|
||||
currentRevision = result.revision;
|
||||
initialSource = result.code;
|
||||
history.pushState(null, "", `/playground/${result.id}/${result.revision}`);
|
||||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||||
elements["share-button"].hidden = false;
|
||||
localStorage.removeItem("yawn:playground:draft");
|
||||
markDirty(false);
|
||||
showToast(`Saved revision ${result.revision}`);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
elements["save-button"].disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
clearTimeout(toastTimer);
|
||||
elements.toast.textContent = message;
|
||||
elements.toast.classList.add("visible");
|
||||
toastTimer = setTimeout(
|
||||
() => elements.toast.classList.remove("visible"),
|
||||
2400,
|
||||
);
|
||||
}
|
||||
|
||||
function insertSnippet(name) {
|
||||
const selection = editor.getSelection();
|
||||
const prefix = model.getValueInRange(selection).endsWith("\n") ? "" : "\n\n";
|
||||
editor.executeEdits("snippet", [
|
||||
{
|
||||
range: selection,
|
||||
text: `${prefix}${snippets[name]}\n`,
|
||||
forceMoveMarkers: true,
|
||||
},
|
||||
]);
|
||||
elements["snippets-dialog"].close();
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function sampleFPS(now = performance.now()) {
|
||||
try {
|
||||
const core = currentCore();
|
||||
if (!core) throw new Error();
|
||||
const frame = Number(core.array("signals").row(0)[1]);
|
||||
if (!sampledAt || frame < sampledFrame) {
|
||||
sampledAt = now;
|
||||
sampledFrame = frame;
|
||||
} else if (now - sampledAt >= 500) {
|
||||
const fps = ((frame - sampledFrame) * 1000) / (now - sampledAt);
|
||||
elements["fps-status"].textContent =
|
||||
`${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`;
|
||||
sampledAt = now;
|
||||
sampledFrame = frame;
|
||||
}
|
||||
} catch {
|
||||
sampledAt = now;
|
||||
sampledFrame = 0;
|
||||
elements["fps-status"].textContent = "0 FPS";
|
||||
}
|
||||
frameRequest = requestAnimationFrame(sampleFPS);
|
||||
}
|
||||
|
||||
elements["run-button"].addEventListener("click", run);
|
||||
elements["save-button"].addEventListener("click", save);
|
||||
elements["share-button"].addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(location.href);
|
||||
showToast("Playground URL copied");
|
||||
});
|
||||
elements["project-title"].addEventListener("input", () => markDirty(true));
|
||||
elements["reset-button"].addEventListener("click", () => {
|
||||
model?.setValue(initialSource);
|
||||
void run();
|
||||
});
|
||||
elements["format-button"].addEventListener("click", () =>
|
||||
editor?.getAction("editor.action.formatDocument").run(),
|
||||
);
|
||||
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) => {
|
||||
if (event.target === elements["snippets-dialog"])
|
||||
elements["snippets-dialog"].close();
|
||||
});
|
||||
for (const button of document.querySelectorAll("[data-snippet]")) {
|
||||
button.addEventListener("click", () => insertSnippet(button.dataset.snippet));
|
||||
}
|
||||
elements["snippets-dialog"]
|
||||
.querySelector("input")
|
||||
.addEventListener("input", (event) => {
|
||||
const query = event.target.value.trim().toLowerCase();
|
||||
for (const button of document.querySelectorAll("[data-snippet]")) {
|
||||
button.hidden =
|
||||
Boolean(query) && !button.textContent.toLowerCase().includes(query);
|
||||
}
|
||||
});
|
||||
for (const button of document.querySelectorAll(
|
||||
".inspector-tabs [data-panel]",
|
||||
)) {
|
||||
button.addEventListener("click", () => openInspector(button.dataset.panel));
|
||||
}
|
||||
elements["clear-console"].addEventListener("click", clearConsole);
|
||||
elements["inspector-button"].addEventListener("click", () => {
|
||||
const collapsed = elements.inspector.classList.toggle("collapsed");
|
||||
elements["inspector-button"].setAttribute("aria-pressed", String(!collapsed));
|
||||
});
|
||||
elements["fullscreen-button"].addEventListener("click", () => {
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
else void document.documentElement.requestFullscreen();
|
||||
});
|
||||
|
||||
elements["resize-handle"].addEventListener("pointerdown", (event) => {
|
||||
if (innerWidth <= 800) return;
|
||||
elements["resize-handle"].setPointerCapture(event.pointerId);
|
||||
elements["resize-handle"].classList.add("dragging");
|
||||
});
|
||||
elements["resize-handle"].addEventListener("pointermove", (event) => {
|
||||
if (!elements["resize-handle"].hasPointerCapture(event.pointerId)) return;
|
||||
const bounds = elements.workbench.getBoundingClientRect();
|
||||
const percentage = Math.min(
|
||||
70,
|
||||
Math.max(30, ((event.clientX - bounds.left) / bounds.width) * 100),
|
||||
);
|
||||
elements.workbench.style.setProperty("--editor-width", `${percentage}%`);
|
||||
});
|
||||
elements["resize-handle"].addEventListener("pointerup", (event) => {
|
||||
elements["resize-handle"].releasePointerCapture(event.pointerId);
|
||||
elements["resize-handle"].classList.remove("dragging");
|
||||
});
|
||||
addEventListener("popstate", () => location.reload());
|
||||
addEventListener("beforeunload", () => {
|
||||
cancelAnimationFrame(frameRequest);
|
||||
void disposeCurrent();
|
||||
});
|
||||
|
||||
try {
|
||||
const source = await loadInitialPlayground();
|
||||
await createEditor(source);
|
||||
markDirty(false);
|
||||
sampleFPS();
|
||||
await run();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
elements.editor.textContent = message;
|
||||
setRunStatus("Editor failed", "failed");
|
||||
showPreviewMessage("Editor could not start", message);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
:root {
|
||||
--ink: #10233e;
|
||||
--muted: #5c6d82;
|
||||
--line: #dfe9f2;
|
||||
--line-strong: #cbdbe9;
|
||||
--sky-25: #f8fcff;
|
||||
--sky-50: #f0f9ff;
|
||||
--sky-100: #e0f2fe;
|
||||
--sky-200: #bae6fd;
|
||||
--sky-400: #38bdf8;
|
||||
--sky-500: #0ea5e9;
|
||||
--sky-600: #0284c7;
|
||||
--blue: #1b8fe5;
|
||||
--blue-deep: #0d6fbd;
|
||||
--navy: #0a1c35;
|
||||
--radius: 16px;
|
||||
--shadow-sm: 0 1px 2px rgb(18 49 77 / 4%), 0 8px 24px rgb(18 49 77 / 5%);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; background: white; color: var(--ink); }
|
||||
a { color: inherit; }
|
||||
button, input { font: inherit; }
|
||||
.section-shell { width: min(1180px, calc(100% - 48px)); margin-inline: auto; }
|
||||
|
||||
.site-header {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
width: min(1240px, calc(100% - 48px));
|
||||
height: 76px;
|
||||
margin: 0 auto;
|
||||
align-items: center;
|
||||
gap: 36px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.wordmark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
color: #102945;
|
||||
font-size: 21px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.04em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wordmark-mark { position: relative; display: inline-block; width: 25px; height: 25px; transform: rotate(-2deg); }
|
||||
.wordmark-mark i { position: absolute; display: block; width: 8px; border: 2.5px solid #218ddd; border-radius: 8px; transform: rotate(-28deg); }
|
||||
.wordmark-mark i:nth-child(1) { left: 2px; top: 10px; height: 12px; }
|
||||
.wordmark-mark i:nth-child(2) { left: 9px; top: 5px; height: 17px; }
|
||||
.wordmark-mark i:nth-child(3) { left: 16px; top: 1px; height: 21px; }
|
||||
.site-nav { display: flex; flex: 1; align-items: center; justify-content: center; gap: 34px; }
|
||||
.site-nav a, .site-footer nav a { color: #53667c; font-size: 14px; font-weight: 550; text-decoration: none; }
|
||||
.site-nav a:hover, .site-footer nav a:hover { color: var(--blue); }
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-height: 46px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 0 21px;
|
||||
color: white;
|
||||
border: 1px solid var(--blue);
|
||||
border-radius: 9px;
|
||||
background: var(--blue);
|
||||
box-shadow: 0 8px 20px rgb(27 143 229 / 16%);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
.button:hover { transform: translateY(-1px); background: var(--blue-deep); box-shadow: 0 11px 25px rgb(27 143 229 / 22%); }
|
||||
.button-small { min-height: 38px; padding: 0 17px; font-size: 13px; }
|
||||
.button-quiet { color: #294661; border-color: var(--line-strong); background: white; box-shadow: none; }
|
||||
.button-quiet:hover { color: var(--blue); background: var(--sky-50); box-shadow: none; }
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 660px;
|
||||
grid-template-columns: minmax(0, .9fr) minmax(540px, 1.1fr);
|
||||
align-items: center;
|
||||
gap: 66px;
|
||||
padding-top: 60px;
|
||||
padding-bottom: 82px;
|
||||
}
|
||||
.hero::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 20px;
|
||||
left: 32%;
|
||||
width: min(600px, 68%);
|
||||
height: 480px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgb(224 242 254 / 85%), transparent 67%);
|
||||
content: "";
|
||||
filter: blur(10px);
|
||||
}
|
||||
.eyebrow { display: flex; align-items: center; gap: 9px; color: var(--sky-600); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.eyebrow > span { width: 22px; height: 2px; border-radius: 2px; background: var(--sky-500); }
|
||||
.hero h1 { max-width: 670px; margin: 24px 0 24px; color: #0e2743; font-size: clamp(48px, 5.4vw, 71px); font-weight: 730; letter-spacing: -.06em; line-height: 1.02; }
|
||||
.hero-lede { max-width: 580px; margin: 0; color: var(--muted); font-size: 18px; line-height: 1.72; }
|
||||
.hero-actions { display: flex; gap: 12px; margin-top: 31px; }
|
||||
.hero-note { display: flex; align-items: center; gap: 11px; margin-top: 27px; color: #7b8b9e; font-size: 12px; }
|
||||
.browser-dots { display: flex; gap: 3px; }
|
||||
.browser-dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--sky-400); }
|
||||
.browser-dots i:nth-child(2) { opacity: .55; }
|
||||
.browser-dots i:nth-child(3) { opacity: .28; }
|
||||
|
||||
.hero-demo { position: relative; overflow: hidden; min-width: 0; border: 1px solid #cee0ed; border-radius: 16px; background: #fff; box-shadow: 0 30px 70px rgb(22 66 102 / 15%), 0 4px 12px rgb(22 66 102 / 7%); transform: perspective(1400px) rotateY(-2.5deg) rotateX(1deg); }
|
||||
.hero-demo::after { position: absolute; right: -100px; bottom: -100px; width: 250px; height: 250px; border-radius: 50%; background: rgb(56 189 248 / 10%); content: ""; filter: blur(35px); }
|
||||
.demo-toolbar { display: flex; height: 44px; align-items: center; justify-content: space-between; padding: 0 16px; color: #7890a6; border-bottom: 1px solid #dbe8f1; background: #f8fbfd; font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.demo-toolbar span { display: flex; align-items: center; gap: 7px; }
|
||||
.demo-toolbar > span > i { width: 7px; height: 7px; border-radius: 2px; background: #49aef1; }
|
||||
.demo-toolbar .demo-status { color: #479165; }
|
||||
.demo-toolbar .demo-status i { border-radius: 50%; background: #38b76b; box-shadow: 0 0 0 3px rgb(56 183 107 / 12%); }
|
||||
.hero-demo pre { min-height: 355px; margin: 0; padding: 25px 27px 28px; overflow: auto; color: #405670; background: #fbfdff; font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; tab-size: 2; }
|
||||
.syntax-purple { color: #9a61d2; }
|
||||
.syntax-blue { color: #207fca; }
|
||||
.syntax-green { color: #268a71; }
|
||||
.syntax-orange { color: #c56b2d; }
|
||||
.demo-preview { position: relative; height: 166px; overflow: hidden; border-top: 1px solid #c9d9e5; background: linear-gradient(145deg, #0a1e38, #0d294b); }
|
||||
.demo-grid { position: absolute; inset: 0; opacity: .25; background-image: linear-gradient(rgb(105 190 249 / 30%) 1px, transparent 1px), linear-gradient(90deg, rgb(105 190 249 / 30%) 1px, transparent 1px); background-size: 30px 30px; transform: perspective(400px) rotateX(64deg) scale(1.4) translateY(26px); }
|
||||
.demo-glow { position: absolute; top: 48%; left: 50%; width: 190px; height: 80px; transform: translate(-50%, -50%); border-radius: 50%; background: #3eb9ff; opacity: .3; filter: blur(35px); }
|
||||
.demo-triangle { position: absolute; top: 25px; left: 50%; width: 125px; height: 112px; transform: translateX(-50%); background: linear-gradient(150deg, #a8e5ff, #258fdf 75%); clip-path: polygon(50% 0, 100% 100%, 0 100%); filter: drop-shadow(0 16px 16px rgb(46 168 240 / 25%)); }
|
||||
.demo-preview > span { position: absolute; right: 12px; bottom: 10px; color: #7494b0; font: 9px/1 ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
|
||||
|
||||
.trust-strip { display: grid; grid-template-columns: repeat(4, 1fr); border-block: 1px solid var(--line); background: var(--sky-25); }
|
||||
.trust-strip div { display: flex; min-height: 92px; align-items: center; justify-content: center; gap: 10px; border-right: 1px solid var(--line); }
|
||||
.trust-strip div:last-child { border-right: 0; }
|
||||
.trust-strip strong { color: #143757; font-size: 16px; letter-spacing: -.03em; }
|
||||
.trust-strip span { color: #718196; font-size: 12px; }
|
||||
|
||||
.progression { padding-top: 115px; padding-bottom: 125px; }
|
||||
.section-heading { max-width: 690px; margin-bottom: 50px; }
|
||||
.section-heading h2, .architecture-copy h2, .cta-section h2 { margin: 16px 0; font-size: clamp(36px, 4vw, 48px); font-weight: 700; letter-spacing: -.05em; line-height: 1.12; }
|
||||
.section-heading > p, .architecture-copy > p, .cta-section p { margin: 0; color: var(--muted); font-size: 16px; line-height: 1.75; }
|
||||
.gear-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 17px; }
|
||||
.gear-card { position: relative; min-height: 390px; padding: 29px; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius); background: white; box-shadow: var(--shadow-sm); }
|
||||
.gear-card::before { position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(145deg, var(--sky-50), transparent 42%); content: ""; opacity: 0; transition: opacity .2s ease; }
|
||||
.gear-card:hover::before { opacity: 1; }
|
||||
.gear-card > * { position: relative; }
|
||||
.gear-card-featured { border-color: #b9ddf6; background: linear-gradient(145deg, #f8fcff, white); }
|
||||
.gear-number { position: absolute; top: 25px; right: 27px; color: #bdd1e0; font: 600 11px/1 ui-monospace, monospace; }
|
||||
.gear-icon { display: grid; width: 52px; height: 52px; margin: 6px 0 33px; place-items: center; border: 1px solid #c6e5f8; border-radius: 13px; background: #eff9ff; }
|
||||
.gear-card h3 { margin: 0 0 14px; color: #13324f; font-size: 20px; letter-spacing: -.025em; }
|
||||
.gear-card p { margin: 0 0 25px; color: #6a7b8e; font-size: 14px; line-height: 1.72; }
|
||||
.gear-card a { position: absolute; bottom: 28px; color: var(--blue-deep); font-size: 13px; font-weight: 700; text-decoration: none; }
|
||||
.gear-card a span { margin-left: 5px; }
|
||||
.cube-icon { width: 21px; height: 21px; border: 2px solid #258fd9; transform: rotate(30deg) skew(-4deg); }
|
||||
.rows-icon { width: 24px; height: 19px; background: repeating-linear-gradient(to bottom, #258fd9 0 2px, transparent 2px 6px); }
|
||||
.graph-icon { position: relative; width: 26px; height: 22px; background: linear-gradient(28deg, transparent 47%, #258fd9 48% 53%, transparent 54%); }
|
||||
.graph-icon::before, .graph-icon::after { position: absolute; width: 7px; height: 7px; border: 2px solid #258fd9; border-radius: 50%; background: #eff9ff; content: ""; }
|
||||
.graph-icon::before { left: 0; bottom: 0; }
|
||||
.graph-icon::after { top: 0; right: 0; }
|
||||
|
||||
.architecture-section { overflow: hidden; padding: 110px 0; color: #d7e9f8; background: radial-gradient(circle at 80% 30%, #153d67, transparent 36%), linear-gradient(145deg, #07192e, #0b2746); }
|
||||
.architecture-grid { display: grid; grid-template-columns: .9fr 1.1fr; align-items: center; gap: 100px; }
|
||||
.eyebrow-light { color: #65c9ff; }
|
||||
.architecture-copy h2, .cta-section h2 { color: #f2f9ff; }
|
||||
.architecture-copy > p { color: #9ab2c8; }
|
||||
.check-list { display: grid; gap: 14px; margin: 30px 0; padding: 0; list-style: none; }
|
||||
.check-list li { color: #bed1e2; font-size: 14px; }
|
||||
.check-list li span { display: inline-grid; width: 20px; height: 20px; margin-right: 10px; place-items: center; border-radius: 50%; color: #6dd0ff; background: rgb(56 189 248 / 12%); font-size: 10px; }
|
||||
.text-link-light { color: #6ed0ff; font-size: 13px; font-weight: 700; text-decoration: none; }
|
||||
.architecture-visual { position: relative; display: flex; min-height: 480px; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.architecture-visual::before { position: absolute; width: 470px; height: 470px; border: 1px solid rgb(106 193 245 / 8%); border-radius: 50%; content: ""; box-shadow: 0 0 0 70px rgb(106 193 245 / 3%), 0 0 0 140px rgb(106 193 245 / 2%); }
|
||||
.flow-label { position: relative; margin-bottom: 11px; color: #6887a3; font: 700 9px/1 ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.flow-nodes { position: relative; display: flex; gap: 10px; }
|
||||
.flow-nodes span { padding: 9px 13px; color: #aac3d8; border: 1px solid #294b69; border-radius: 6px; background: #102d4b; font: 10px/1 ui-monospace, monospace; }
|
||||
.flow-line { position: relative; display: flex; width: 250px; height: 41px; justify-content: space-around; }
|
||||
.flow-line i { width: 1px; height: 100%; background: linear-gradient(#356688, #48b9f4); }
|
||||
.arena-card { position: relative; width: min(410px, 100%); padding: 20px; border: 1px solid #2f8bc0; border-radius: 12px; background: linear-gradient(120deg, #0c3254, #0d2a47); box-shadow: 0 0 40px rgb(27 143 229 / 12%); }
|
||||
.arena-card > div:first-child { display: grid; grid-template-columns: 20px 1fr; }
|
||||
.arena-card > div > span { width: 11px; height: 11px; margin-top: 3px; border: 2px solid #62c9ff; border-radius: 3px; }
|
||||
.arena-card strong { color: #d9f0ff; font: 600 13px/1.2 ui-monospace, monospace; }
|
||||
.arena-card small { grid-column: 2; margin-top: 3px; color: #6389a6; font: 9px/1 ui-monospace, monospace; }
|
||||
.arena-rows { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; margin-top: 18px; }
|
||||
.arena-rows i { height: 30px; border: 1px solid #285f84; border-radius: 3px; background: repeating-linear-gradient(90deg, rgb(69 178 235 / 18%) 0 5px, transparent 5px 8px); }
|
||||
.flow-line-bottom { width: 1px; height: 34px; }
|
||||
.core-card { position: relative; display: flex; width: 260px; min-height: 66px; flex-direction: column; align-items: center; justify-content: center; border: 1px solid #365b7a; border-radius: 9px; background: #102b46; }
|
||||
.core-card span { color: #6c8ca8; font: 8px/1 ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.core-card strong { margin-top: 7px; color: #c7deef; font-size: 12px; }
|
||||
.gpu-line { position: relative; width: 1px; height: 28px; background: #315a77; }
|
||||
.gpu-card { position: relative; padding: 8px 21px; color: #83d5ff; border: 1px solid #2c6f98; border-radius: 20px; background: #0c304f; font: 700 9px/1 ui-monospace, monospace; letter-spacing: .1em; }
|
||||
|
||||
.steps-section { padding-top: 115px; padding-bottom: 120px; }
|
||||
.section-heading-centered { margin-inline: auto; text-align: center; }
|
||||
.section-heading-centered .eyebrow { justify-content: center; }
|
||||
.steps { max-width: 880px; margin: 55px auto 0; padding: 0; border-top: 1px solid var(--line); list-style: none; }
|
||||
.steps li { display: grid; min-height: 126px; grid-template-columns: 46px 1fr auto; align-items: center; gap: 24px; border-bottom: 1px solid var(--line); }
|
||||
.step-index { display: grid; width: 34px; height: 34px; place-items: center; color: var(--blue); border: 1px solid #b9dcf4; border-radius: 50%; background: var(--sky-50); font: 700 11px/1 ui-monospace, monospace; }
|
||||
.steps h3 { margin: 0 0 5px; font-size: 16px; }
|
||||
.steps p { margin: 0; color: #708196; font-size: 13px; }
|
||||
.steps code { padding: 10px 13px; color: #2977ad; border: 1px solid #d9e7f1; border-radius: 6px; background: #f8fbfd; font: 11px/1 ui-monospace, monospace; }
|
||||
.cta-section { display: flex; min-height: 305px; align-items: center; justify-content: space-between; gap: 50px; margin-bottom: 90px; padding: 56px 65px; border-radius: 20px; background: radial-gradient(circle at 85% 20%, #229eea, transparent 35%), linear-gradient(125deg, #0a2542, #0c4b78); box-shadow: 0 24px 55px rgb(12 64 101 / 18%); }
|
||||
.cta-section > div { max-width: 700px; }
|
||||
.cta-section h2 { margin-block: 13px; font-size: 39px; }
|
||||
.cta-section p { color: #a7c4d9; }
|
||||
.button-white { flex: 0 0 auto; color: #155a89; border-color: white; background: white; box-shadow: none; }
|
||||
.button-white:hover { color: #0f70ae; background: #f0f9ff; box-shadow: none; }
|
||||
.site-footer { display: grid; min-height: 100px; grid-template-columns: 1fr 1fr 1fr; align-items: center; border-top: 1px solid var(--line); }
|
||||
.site-footer p { color: #8694a5; font-size: 12px; text-align: center; }
|
||||
.site-footer nav { display: flex; justify-content: flex-end; gap: 24px; }
|
||||
|
||||
[data-reveal] { opacity: 0; transform: translateY(12px); transition: opacity 500ms ease, transform 500ms ease; }
|
||||
[data-reveal].revealed { opacity: 1; transform: none; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero { grid-template-columns: 1fr; padding-top: 80px; }
|
||||
.hero-copy { max-width: 700px; }
|
||||
.hero-demo { width: min(680px, 100%); transform: none; }
|
||||
.gear-grid { grid-template-columns: 1fr; }
|
||||
.gear-card { min-height: 320px; }
|
||||
.architecture-grid { grid-template-columns: 1fr; gap: 60px; }
|
||||
.architecture-copy { max-width: 700px; }
|
||||
.trust-strip { grid-template-columns: 1fr 1fr; }
|
||||
.trust-strip div:nth-child(2) { border-right: 0; }
|
||||
.trust-strip div:nth-child(-n + 2) { border-bottom: 1px solid var(--line); }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.section-shell, .site-header { width: min(100% - 28px, 1180px); }
|
||||
.site-header { height: 64px; gap: 14px; }
|
||||
.site-nav { justify-content: flex-end; gap: 16px; }
|
||||
.site-nav a:last-child { display: none; }
|
||||
.site-header > .button { display: none; }
|
||||
.hero { min-height: auto; grid-template-columns: minmax(0, 1fr); gap: 45px; padding-block: 60px; }
|
||||
.hero h1 { font-size: 47px; }
|
||||
.hero-lede { font-size: 16px; }
|
||||
.hero-actions { align-items: stretch; flex-direction: column; }
|
||||
.hero-demo pre { font-size: 10px; }
|
||||
.trust-strip { grid-template-columns: 1fr; }
|
||||
.trust-strip div { min-height: 72px; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.progression, .steps-section { padding-block: 80px; }
|
||||
.architecture-section { padding-block: 80px; }
|
||||
.architecture-visual { transform: scale(.82); margin-inline: -35px; }
|
||||
.steps li { grid-template-columns: 42px 1fr; padding-block: 22px; }
|
||||
.steps code { display: none; }
|
||||
.cta-section { align-items: flex-start; flex-direction: column; margin-bottom: 45px; padding: 36px 28px; }
|
||||
.site-footer { grid-template-columns: 1fr; gap: 18px; padding-block: 28px; text-align: center; }
|
||||
.site-footer p { margin: 0; }
|
||||
.site-footer nav { justify-content: center; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const revealTargets = document.querySelectorAll(
|
||||
".section-heading, .gear-card, .architecture-copy, .architecture-visual, .steps li, .cta-section",
|
||||
);
|
||||
|
||||
if ("IntersectionObserver" in window && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
for (const target of revealTargets) target.dataset.reveal = "";
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
entry.target.classList.add("revealed");
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -40px", threshold: 0.08 },
|
||||
);
|
||||
for (const target of revealTargets) observer.observe(target);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
Vendored
+324
@@ -0,0 +1,324 @@
|
||||
declare module "@yawn/core" {
|
||||
export type RowFormat = "f32" | "u32" | "i32";
|
||||
export interface RowDescriptor {
|
||||
name: string;
|
||||
rows: number;
|
||||
stride: number;
|
||||
format: RowFormat;
|
||||
offset: number;
|
||||
bytes: number;
|
||||
}
|
||||
export class SharedRows {
|
||||
readonly buffer: SharedArrayBuffer;
|
||||
readonly descriptor: RowDescriptor;
|
||||
readonly name: string;
|
||||
readonly rows: number;
|
||||
readonly stride: number;
|
||||
readonly format: RowFormat;
|
||||
readonly view: Float32Array | Uint32Array | Int32Array;
|
||||
row(index: number): Float32Array | Uint32Array | Int32Array;
|
||||
read(index: number): number[];
|
||||
write(index: number, values: ArrayLike<number>): this;
|
||||
share(): { buffer: SharedArrayBuffer; descriptor: RowDescriptor };
|
||||
}
|
||||
export interface ProfileFrame {
|
||||
frame: number;
|
||||
milliseconds: number;
|
||||
readbackMilliseconds: number;
|
||||
adapter: string;
|
||||
canvas: { width: number; height: number };
|
||||
passes: Array<{ name: string; milliseconds: number }>;
|
||||
}
|
||||
export class YawnCore {
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
options?: { arenaBytes?: number; debug?: boolean },
|
||||
);
|
||||
readonly ready: Promise<void>;
|
||||
createRows(options: {
|
||||
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>;
|
||||
allocateObject(name: string): Promise<number>;
|
||||
deleteObject(name: string, id: number): Promise<void>;
|
||||
compileGraph(serialized: string): Promise<string>;
|
||||
switchLoadout(id: string): Promise<void>;
|
||||
play(): Promise<void>;
|
||||
pause(): Promise<void>;
|
||||
setFps(fps: number): Promise<void>;
|
||||
setProfiler(enabled: boolean): Promise<boolean>;
|
||||
onProfile(listener: (frame: ProfileFrame) => void): () => void;
|
||||
array(name: string): SharedRows;
|
||||
dispose(): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@yawn/handles" {
|
||||
import { SharedRows, YawnCore } from "@yawn/core";
|
||||
|
||||
export interface SceneOptions {
|
||||
arenaBytes?: number;
|
||||
debug?: boolean;
|
||||
fps?: number;
|
||||
hdr?: boolean;
|
||||
}
|
||||
export class Scene {
|
||||
constructor(canvas: HTMLCanvasElement, options?: SceneOptions);
|
||||
readonly core: YawnCore;
|
||||
readonly ready: Promise<this>;
|
||||
readonly hdr: boolean;
|
||||
array(name: string): SharedRows;
|
||||
ensureRows(
|
||||
name: string,
|
||||
rows: number,
|
||||
stride: number,
|
||||
format: "f32" | "u32" | "i32",
|
||||
): Promise<SharedRows>;
|
||||
reserve(additional: { nodes?: number; materials?: number }): Promise<void>;
|
||||
batchWrites<T>(operation: () => T): T;
|
||||
batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface Vector3View {
|
||||
x: 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 {
|
||||
constructor(scene: Scene, options?: NodeOptions);
|
||||
readonly scene: Scene;
|
||||
readonly ready: Promise<this>;
|
||||
readonly id: number;
|
||||
readonly position: Vector3View;
|
||||
readonly rotor: RotorView;
|
||||
readonly scale: Vector3View;
|
||||
setPosition(value: ArrayLike<number>): this;
|
||||
setRotor(value: ArrayLike<number>): this;
|
||||
setScale(value: ArrayLike<number>): this;
|
||||
translate(x: number, y: number, z: number): this;
|
||||
rotateX(radians: number): this;
|
||||
rotateY(radians: number): this;
|
||||
rotateZ(radians: number): this;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PBRMaterialOptions {
|
||||
baseColor?: ArrayLike<number>;
|
||||
metallic?: number;
|
||||
roughness?: number;
|
||||
emissive?: ArrayLike<number>;
|
||||
}
|
||||
export class PBRMaterial {
|
||||
constructor(scene: Scene, options?: PBRMaterialOptions);
|
||||
readonly ready: Promise<this>;
|
||||
readonly id: number;
|
||||
metallic: number;
|
||||
roughness: number;
|
||||
readonly baseColor: { 0: number; 1: number; 2: number; 3: number };
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export type VertexKind =
|
||||
| "positions"
|
||||
| "normals"
|
||||
| "tangents"
|
||||
| "uvs"
|
||||
| "colors"
|
||||
| "indices";
|
||||
export interface MeshOptions extends NodeOptions {
|
||||
geometryId?: number;
|
||||
material?: PBRMaterial;
|
||||
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||
visible?: boolean;
|
||||
}
|
||||
export class Mesh extends Node {
|
||||
constructor(scene: Scene, options?: MeshOptions);
|
||||
geometryId: number;
|
||||
vertexCount: number;
|
||||
indexCount: number;
|
||||
isVisible: boolean;
|
||||
material: PBRMaterial;
|
||||
clone(options?: Omit<MeshOptions, "geometryId" | "vertexData">): Mesh;
|
||||
setVertexData(kind: VertexKind, data: ArrayLike<number>): Promise<this>;
|
||||
setMaterialForFaces(
|
||||
material: PBRMaterial,
|
||||
faces: number | number[],
|
||||
): Promise<this>;
|
||||
}
|
||||
|
||||
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 {
|
||||
fov: number;
|
||||
near: number;
|
||||
far: number;
|
||||
aspect: number;
|
||||
}
|
||||
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;
|
||||
};
|
||||
},
|
||||
);
|
||||
alpha: number;
|
||||
beta: number;
|
||||
radius: number;
|
||||
detachControls(): this;
|
||||
}
|
||||
export class FreeCamera extends Camera {
|
||||
constructor(
|
||||
scene: Scene,
|
||||
options?: CameraOptions & {
|
||||
controls?: {
|
||||
element: HTMLElement;
|
||||
keyboard?: boolean;
|
||||
pointer?: boolean;
|
||||
controller?: boolean;
|
||||
speed?: number;
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
export class FollowCamera extends Camera {
|
||||
constructor(
|
||||
scene: Scene,
|
||||
options?: CameraOptions & {
|
||||
target?: Node;
|
||||
distance?: number;
|
||||
height?: number;
|
||||
smoothing?: number;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface LightOptions extends NodeOptions {
|
||||
color?: ArrayLike<number>;
|
||||
intensity?: number;
|
||||
}
|
||||
export class PointLight extends Node {
|
||||
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 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[]>;
|
||||
}
|
||||
|
||||
declare const canvas: HTMLCanvasElement;
|
||||
declare function log(...values: unknown[]): void;
|
||||
@@ -0,0 +1,539 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Learn Yawn from your first WebGPU scene through shared memory and custom render graphs."
|
||||
/>
|
||||
<title>Learn Yawn — Documentation</title>
|
||||
<script src="/assets/theme.js"></script>
|
||||
<link rel="stylesheet" href="/assets/site.css" />
|
||||
<link rel="stylesheet" href="/assets/docs.css" />
|
||||
<link rel="stylesheet" href="/assets/theme.css" />
|
||||
<script type="module" src="/assets/docs.js"></script>
|
||||
</head>
|
||||
<body class="docs-body">
|
||||
<header class="site-header docs-header">
|
||||
<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>
|
||||
</a>
|
||||
<span class="docs-product">Docs</span>
|
||||
<label class="docs-search">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Filter this guide"
|
||||
aria-label="Filter documentation sections"
|
||||
/>
|
||||
<kbd>/</kbd>
|
||||
</label>
|
||||
<nav class="site-nav" aria-label="Documentation navigation">
|
||||
<a href="/playground">Playground</a
|
||||
><a href="https://git.heaust.org/heaust/yawn">Source</a>
|
||||
</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>
|
||||
|
||||
<div class="docs-layout">
|
||||
<aside class="docs-sidebar" aria-label="Documentation sections">
|
||||
<div class="sidebar-group">
|
||||
<strong>Start here</strong>
|
||||
<a href="#welcome" class="active">Welcome to Yawn</a>
|
||||
<a href="#installation">Installation</a>
|
||||
<a href="#first-scene">Your first scene</a>
|
||||
<a href="#camera">Add a camera</a>
|
||||
</div>
|
||||
<div class="sidebar-group">
|
||||
<strong>Build a world</strong>
|
||||
<a href="#meshes">Meshes and instances</a>
|
||||
<a href="#materials">Materials and lights</a>
|
||||
<a href="#models">Import a glTF model</a>
|
||||
<a href="#post-processing">Post processing</a>
|
||||
</div>
|
||||
<div class="sidebar-group">
|
||||
<strong>Understand Yawn</strong>
|
||||
<a href="#mental-model">The mental model</a>
|
||||
<a href="#shared-memory">Shared memory</a>
|
||||
<a href="#profiler">GPU profiler</a>
|
||||
</div>
|
||||
<div class="sidebar-group">
|
||||
<strong>Deep dives</strong>
|
||||
<a href="#core-deep-dive">Using core directly</a>
|
||||
<a href="#custom-rows">Craft your own API</a>
|
||||
<a href="#deployment">Deployment</a>
|
||||
</div>
|
||||
<a class="sidebar-playground" href="/playground"
|
||||
><span>▶</span>
|
||||
<div>
|
||||
<strong>Try as you learn</strong><small>Open the playground</small>
|
||||
</div></a
|
||||
>
|
||||
</aside>
|
||||
|
||||
<main class="docs-main">
|
||||
<article>
|
||||
<section id="welcome" data-title="Welcome to Yawn">
|
||||
<div class="doc-eyebrow">Start here · 5 minute read</div>
|
||||
<h1>Build your first Yawn scene</h1>
|
||||
<p class="doc-lede">
|
||||
This guide starts from an empty HTML file and ends with a WebGPU
|
||||
triangle you can move, shade, and inspect. You do not need engine
|
||||
experience. Every new term is explained where it first appears.
|
||||
</p>
|
||||
<div class="callout callout-blue">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section id="installation" data-title="Installation">
|
||||
<div class="section-kicker"><span>01</span> Installation</div>
|
||||
<h2>Load Handles from Yawn’s CDN</h2>
|
||||
<p>
|
||||
You do not build or host Yawn yourself. An
|
||||
<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><head></code>:
|
||||
</p>
|
||||
<div class="code-block" data-language="html">
|
||||
<div class="code-title">
|
||||
<span>index.html</span
|
||||
><button type="button" data-copy>Copy</button>
|
||||
</div>
|
||||
<pre><code><script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@yawn/core": "https://yawn.heaust.org/pkg/core.js",
|
||||
"@yawn/handles": "https://yawn.heaust.org/pkg/handles.js"
|
||||
}
|
||||
}
|
||||
</script></code></pre>
|
||||
</div>
|
||||
<p>
|
||||
Core and Handles stay separate: Handles uses the mapped Core
|
||||
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>
|
||||
<h3>Make a canvas</h3>
|
||||
<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-title">
|
||||
<span>index.html</span
|
||||
><button type="button" data-copy>Copy</button>
|
||||
</div>
|
||||
<pre><code><canvas id="view" width="1280" height="720"></canvas>
|
||||
<script type="module" src="/app.js"></script></code></pre>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section id="first-scene" data-title="Your first scene">
|
||||
<div class="section-kicker"><span>02</span> Your first scene</div>
|
||||
<h2>Scene → material → mesh</h2>
|
||||
<p>
|
||||
A <strong>scene</strong> owns the shared data and render graph. A
|
||||
<strong>material</strong> describes the surface. A
|
||||
<strong>mesh</strong> supplies points and tells Yawn which order
|
||||
to connect them.
|
||||
</p>
|
||||
<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>
|
||||
<pre><code>import "@yawn/core";
|
||||
import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||
|
||||
const canvas = document.querySelector("#view");
|
||||
const scene = new Scene(canvas, { hdr: true, fps: 60 });
|
||||
await scene.ready;
|
||||
|
||||
const sky = new PBRMaterial(scene, {
|
||||
baseColor: [0.12, 0.58, 1, 1],
|
||||
metallic: 0.15,
|
||||
roughness: 0.35,
|
||||
});
|
||||
await sky.ready;
|
||||
|
||||
const triangle = new Mesh(scene, {
|
||||
material: sky,
|
||||
vertexData: {
|
||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await triangle.ready;</code></pre>
|
||||
</div>
|
||||
<div class="explain-grid">
|
||||
<div>
|
||||
<code>new</code>
|
||||
<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>
|
||||
<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 id="camera" data-title="Add a camera">
|
||||
<div class="section-kicker"><span>03</span> Move around</div>
|
||||
<h2>Add a camera you can orbit</h2>
|
||||
<p>
|
||||
The starter triangle is already in clip space, so it is visible
|
||||
without a camera. For a 3D world, add an
|
||||
<code>ArcRotateCamera</code>. Drag to orbit and use the wheel to
|
||||
zoom.
|
||||
</p>
|
||||
<div class="code-block" data-language="typescript">
|
||||
<div class="code-title">
|
||||
<span>app.ts</span><button type="button" data-copy>Copy</button>
|
||||
</div>
|
||||
<pre><code>import { ArcRotateCamera } from "@yawn/handles";
|
||||
|
||||
const camera = new ArcRotateCamera(scene, {
|
||||
target: triangle,
|
||||
alpha: 0,
|
||||
beta: Math.PI / 2,
|
||||
radius: 3,
|
||||
controls: { element: canvas, pointer: true },
|
||||
});
|
||||
await camera.ready;</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="meshes" data-title="Meshes and instances">
|
||||
<div class="section-kicker"><span>04</span> Build a world</div>
|
||||
<h2>Clone geometry, not work</h2>
|
||||
<p>
|
||||
Every <code>Mesh</code> is an instance. Calling
|
||||
<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.
|
||||
</p>
|
||||
<div class="code-block" data-language="typescript">
|
||||
<div class="code-title">
|
||||
<span>instances.ts</span
|
||||
><button type="button" data-copy>Copy</button>
|
||||
</div>
|
||||
<pre><code>for (let x = -4; x <= 4; x++) {
|
||||
const copy = triangle.clone({ position: [x * 0.35, 0, 0] });
|
||||
await copy.ready;
|
||||
}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="materials" data-title="Materials and lights">
|
||||
<h2>Materials and lights are ordinary handles</h2>
|
||||
<p>
|
||||
Change a material after it is ready and Yawn writes the new value
|
||||
directly into its shared row. Lights use the same pattern.
|
||||
</p>
|
||||
<div class="code-block" data-language="typescript">
|
||||
<div class="code-title">
|
||||
<span>lighting.ts</span
|
||||
><button type="button" data-copy>Copy</button>
|
||||
</div>
|
||||
<pre><code>import { AmbientLight, PointLight } from "@yawn/handles";
|
||||
|
||||
const key = new PointLight(scene, {
|
||||
position: [0, 1, 1],
|
||||
color: [1, 0.72, 0.5],
|
||||
intensity: 12,
|
||||
range: 8,
|
||||
});
|
||||
const fill = new AmbientLight(scene, {
|
||||
color: [0.08, 0.2, 0.5],
|
||||
intensity: 0.3,
|
||||
});
|
||||
await Promise.all([key.ready, fill.ready]);
|
||||
|
||||
sky.roughness = 0.5; // one shared-memory write</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="models" data-title="Import a glTF model">
|
||||
<h2>Bring in a glTF model</h2>
|
||||
<p>
|
||||
<code>importGltf</code> fetches and parses <code>.gltf</code> or
|
||||
<code>.glb</code> data in a worker, then creates ordinary Yawn
|
||||
meshes and materials.
|
||||
</p>
|
||||
<div class="code-block" data-language="typescript">
|
||||
<div class="code-title">
|
||||
<span>model.ts</span
|
||||
><button type="button" data-copy>Copy</button>
|
||||
</div>
|
||||
<pre><code>import { importGltf } from "@yawn/handles";
|
||||
|
||||
const meshes = await importGltf(scene, "/models/robot.glb");
|
||||
meshes[0].position.y = 0.5;</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="post-processing" data-title="Post processing">
|
||||
<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>
|
||||
<div class="code-block" data-language="typescript">
|
||||
<div class="code-title">
|
||||
<span>effects.ts</span
|
||||
><button type="button" data-copy>Copy</button>
|
||||
</div>
|
||||
<pre><code>import { ColorGrading, FXAA } from "@yawn/handles";
|
||||
|
||||
await scene.batchGraphUpdates(async () => {
|
||||
const grade = new ColorGrading(scene, { toneMap: "aces" });
|
||||
const fxaa = new FXAA(scene);
|
||||
await Promise.all([grade.ready, fxaa.ready]);
|
||||
});</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="mental-model" data-title="The mental model">
|
||||
<div class="section-kicker"><span>05</span> Understand Yawn</div>
|
||||
<h2>Setup is messages. Motion is memory.</h2>
|
||||
<p>
|
||||
Expensive structural changes—creating rows, allocating IDs, or
|
||||
replacing a render graph—go to the render worker as messages.
|
||||
Values that already exist—positions, colors, camera matrices,
|
||||
light strengths—change in shared memory.
|
||||
</p>
|
||||
<div class="mental-model">
|
||||
<div>
|
||||
<strong>Infrequent control</strong
|
||||
><span>create · allocate · compile · switch</span>
|
||||
</div>
|
||||
<i>→</i>
|
||||
<div class="mental-shared">
|
||||
<strong>Shared rows</strong
|
||||
><span>transform · shade · animate</span>
|
||||
</div>
|
||||
<i>→</i>
|
||||
<div>
|
||||
<strong>Rust/WASM core</strong
|
||||
><span>schedule · upload · render</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="shared-memory" data-title="Shared memory">
|
||||
<h2>Write your own hot data</h2>
|
||||
<p>
|
||||
Handles are views over named rows. Your application can add rows
|
||||
too. Each row is aligned for predictable CPU and GPU use.
|
||||
</p>
|
||||
<div class="code-block" data-language="typescript">
|
||||
<div class="code-title">
|
||||
<span>simulation.ts</span
|
||||
><button type="button" data-copy>Copy</button>
|
||||
</div>
|
||||
<pre><code>const velocity = await scene.ensureRows(
|
||||
"app.velocity", 10_000, 16, "f32"
|
||||
);
|
||||
|
||||
velocity.row(42).set([1, 0, 0, 0]);</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
That final line changes four floats without cloning an object or
|
||||
posting a worker message. The scene marks shared data dirty so the
|
||||
next frame sees it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="profiler" data-title="GPU profiler">
|
||||
<h2>Measure the passes the GPU actually ran</h2>
|
||||
<p>
|
||||
Open <strong>Profile</strong> in the playground to enable
|
||||
timestamp queries. Yawn reports the physical pass names and GPU
|
||||
milliseconds without serializing the render queue. Support depends
|
||||
on the browser and adapter.
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section id="core-deep-dive" data-title="Using core directly">
|
||||
<div class="section-kicker"><span>06</span> Deep dive</div>
|
||||
<h2>Outgrow handles without outgrowing Yawn</h2>
|
||||
<p>
|
||||
<code>@yawn/core</code> has no scene, mesh, material, camera, or
|
||||
built-in shader. It owns a shared arena and a render-graph
|
||||
runtime. Handles are one replaceable frontend that builds on those
|
||||
two primitives.
|
||||
</p>
|
||||
<div class="code-block" data-language="typescript">
|
||||
<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";
|
||||
|
||||
const core = new YawnCore(canvas, {
|
||||
arenaBytes: 64 * 1024 * 1024,
|
||||
});
|
||||
await core.ready;
|
||||
|
||||
const particles = await core.createRows({
|
||||
name: "particles",
|
||||
rows: 100_000,
|
||||
stride: 16,
|
||||
format: "f32",
|
||||
});</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
From here, your frontend supplies WGSL, resources, pipelines, and
|
||||
a pass DAG. Core compiles that description into an up-front
|
||||
loadout, aliases compatible transient textures, and records render
|
||||
work.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="custom-rows" data-title="Craft your own API">
|
||||
<h2>Craft an API for your problem</h2>
|
||||
<p>
|
||||
A custom handle can be as small as an ID plus getters and setters
|
||||
into shared rows. Keep domain policy in your code and send only
|
||||
structural changes to core.
|
||||
</p>
|
||||
<div class="code-block" data-language="typescript">
|
||||
<div class="code-title">
|
||||
<span>Particle.ts</span
|
||||
><button type="button" data-copy>Copy</button>
|
||||
</div>
|
||||
<pre><code>class Particle {
|
||||
constructor(
|
||||
readonly id: number,
|
||||
readonly positions: SharedRows,
|
||||
) {}
|
||||
|
||||
set x(value: number) {
|
||||
this.positions.row(this.id)[0] = value;
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="deployment" data-title="Deployment">
|
||||
<h2>Deployment checklist</h2>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<aside class="docs-toc" aria-label="On this page">
|
||||
<strong>On this page</strong>
|
||||
<a href="#installation">Installation</a>
|
||||
<a href="#first-scene">Your first scene</a>
|
||||
<a href="#camera">Add a camera</a>
|
||||
<a href="#mental-model">The mental model</a>
|
||||
<a href="#core-deep-dive">Using core directly</a>
|
||||
<a href="#deployment">Deployment</a>
|
||||
</aside>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,272 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#f7fbff" />
|
||||
<meta
|
||||
name="description"
|
||||
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>
|
||||
<script src="/assets/theme.js"></script>
|
||||
<link rel="stylesheet" href="/assets/site.css" />
|
||||
<link rel="stylesheet" href="/assets/theme.css" />
|
||||
<script type="module" src="/assets/site.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<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>
|
||||
</a>
|
||||
<nav class="site-nav" aria-label="Main navigation">
|
||||
<a href="/docs">Docs</a>
|
||||
<a href="/playground">Playground</a>
|
||||
<a href="https://git.heaust.org/heaust/yawn">Source</a>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero section-shell">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow"><span></span> WebGPU without the ceremony</div>
|
||||
<h1>Start with a scene.<br />Scale into an engine.</h1>
|
||||
<p class="hero-lede">
|
||||
Yawn gives you approachable TypeScript handles on day one, direct
|
||||
shared-memory performance when you need it, and a programmable
|
||||
render core when your ideas outgrow conventions.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button" href="/playground"
|
||||
>Open the playground <span>→</span></a
|
||||
>
|
||||
<a class="button button-quiet" href="/docs#installation"
|
||||
>Read the 5-minute setup</a
|
||||
>
|
||||
</div>
|
||||
<div class="hero-note">
|
||||
<span class="browser-dots" aria-hidden="true"
|
||||
><i></i><i></i><i></i
|
||||
></span>
|
||||
Separate Core + Handles modules · No framework required · Rust/WASM
|
||||
core
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-demo" aria-label="A short Yawn scene example">
|
||||
<div class="demo-toolbar">
|
||||
<span><i></i> app.ts</span>
|
||||
<span class="demo-status"><i></i> 60 FPS</span>
|
||||
</div>
|
||||
<pre><code><span class="syntax-purple">import</span> { Scene, Mesh, PBRMaterial }
|
||||
<span class="syntax-purple">from</span> <span class="syntax-green">"@yawn/handles"</span>;
|
||||
|
||||
<span class="syntax-purple">const</span> scene = <span class="syntax-purple">new</span> <span class="syntax-blue">Scene</span>(canvas);
|
||||
<span class="syntax-purple">await</span> scene.ready;
|
||||
|
||||
<span class="syntax-purple">const</span> blue = <span class="syntax-purple">new</span> <span class="syntax-blue">PBRMaterial</span>(scene, {
|
||||
baseColor: [<span class="syntax-orange">0.12</span>, <span class="syntax-orange">0.58</span>, <span class="syntax-orange">1</span>, <span class="syntax-orange">1</span>],
|
||||
roughness: <span class="syntax-orange">0.35</span>,
|
||||
});
|
||||
|
||||
<span class="syntax-purple">const</span> triangle = <span class="syntax-purple">new</span> <span class="syntax-blue">Mesh</span>(scene, {
|
||||
material: blue,
|
||||
vertexData: { positions, indices },
|
||||
});</code></pre>
|
||||
<div class="demo-preview" aria-hidden="true">
|
||||
<div class="demo-grid"></div>
|
||||
<div class="demo-glow"></div>
|
||||
<div class="demo-triangle"></div>
|
||||
<span>rendering on WebGPU</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="trust-strip" aria-label="Yawn principles">
|
||||
<div><strong>One</strong><span>shared arena</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>Small</strong><span>Rust/WASM foundation</span></div>
|
||||
</section>
|
||||
|
||||
<section class="section-shell progression" id="progression">
|
||||
<div class="section-heading">
|
||||
<div class="eyebrow"><span></span> Progressive by design</div>
|
||||
<h2>Use exactly as much engine as you need.</h2>
|
||||
<p>
|
||||
You do not have to choose between an easy API and a serious
|
||||
foundation. Yawn keeps those layers separate, so learning more never
|
||||
means starting over.
|
||||
</p>
|
||||
</div>
|
||||
<div class="gear-grid">
|
||||
<article class="gear-card gear-card-featured">
|
||||
<div class="gear-number">01</div>
|
||||
<div class="gear-icon"><span class="cube-icon"></span></div>
|
||||
<h3>Build with handles</h3>
|
||||
<p>
|
||||
Start with familiar scenes, meshes, materials, lights, cameras,
|
||||
glTF imports, and post effects. Constructors are explicit and
|
||||
every async boundary is visible.
|
||||
</p>
|
||||
<a href="/docs#first-scene">Make your first scene <span>→</span></a>
|
||||
</article>
|
||||
<article class="gear-card">
|
||||
<div class="gear-number">02</div>
|
||||
<div class="gear-icon"><span class="rows-icon"></span></div>
|
||||
<h3>Move through shared data</h3>
|
||||
<p>
|
||||
Positions, cameras, lights, materials, and your own application
|
||||
rows live in one SharedArrayBuffer. Hot updates become direct
|
||||
typed-array writes.
|
||||
</p>
|
||||
<a href="/docs#shared-memory"
|
||||
>Understand the fast path <span>→</span></a
|
||||
>
|
||||
</article>
|
||||
<article class="gear-card">
|
||||
<div class="gear-number">03</div>
|
||||
<div class="gear-icon"><span class="graph-icon"></span></div>
|
||||
<h3>Author the whole graph</h3>
|
||||
<p>
|
||||
Use core directly when you need custom resources, WGSL pipelines,
|
||||
compute, pass dependencies, transient aliasing, and up-front GPU
|
||||
loadouts.
|
||||
</p>
|
||||
<a href="/docs#core-deep-dive">Go beneath handles <span>→</span></a>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="architecture-section">
|
||||
<div class="section-shell architecture-grid">
|
||||
<div class="architecture-copy">
|
||||
<div class="eyebrow eyebrow-light">
|
||||
<span></span> A quiet hot path
|
||||
</div>
|
||||
<h2>Your frame should move data, not negotiate it.</h2>
|
||||
<p>
|
||||
Yawn pays setup costs when your scene or graph changes. During
|
||||
play, ordinary updates stay in shared rows that JavaScript and the
|
||||
render worker can both see.
|
||||
</p>
|
||||
<ul class="check-list">
|
||||
<li>
|
||||
<span>✓</span> Structure-of-arrays layout with aligned rows
|
||||
</li>
|
||||
<li>
|
||||
<span>✓</span> Render graphs compiled before they become active
|
||||
</li>
|
||||
<li>
|
||||
<span>✓</span> Optional timestamp profiling for physical GPU
|
||||
passes
|
||||
</li>
|
||||
</ul>
|
||||
<a class="text-link-light" href="/docs#mental-model"
|
||||
>See how the pieces connect →</a
|
||||
>
|
||||
</div>
|
||||
<div class="architecture-visual" aria-label="Yawn data flow">
|
||||
<div class="flow-label">Your application</div>
|
||||
<div class="flow-nodes flow-top">
|
||||
<span>handles</span><span>workers</span><span>game logic</span>
|
||||
</div>
|
||||
<div class="flow-line"><i></i><i></i><i></i></div>
|
||||
<div class="arena-card">
|
||||
<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 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="gpu-line"></div>
|
||||
<div class="gpu-card">WebGPU</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-shell steps-section">
|
||||
<div class="section-heading section-heading-centered">
|
||||
<div class="eyebrow"><span></span> From blank page to pixels</div>
|
||||
<h2>A small API with visible boundaries.</h2>
|
||||
</div>
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<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>
|
||||
<code>new Scene(canvas)</code>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
<code>new Mesh(scene, options)</code>
|
||||
</li>
|
||||
<li>
|
||||
<span class="step-index">3</span>
|
||||
<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>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="cta-section section-shell">
|
||||
<div>
|
||||
<div class="eyebrow eyebrow-light">
|
||||
<span></span> The best way to understand it
|
||||
</div>
|
||||
<h2>Change the code. Watch the passes.</h2>
|
||||
<p>
|
||||
The playground includes TypeScript intelligence, reusable snippets,
|
||||
live output, shareable SQLite-backed revisions, and the engine’s
|
||||
real GPU profiler.
|
||||
</p>
|
||||
</div>
|
||||
<a class="button button-white" href="/playground"
|
||||
>Launch playground <span>↗</span></a
|
||||
>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<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
|
||||
>
|
||||
<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>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,288 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#f7fbff" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Write, run, profile, and share Yawn WebGPU scenes."
|
||||
/>
|
||||
<title>Yawn Playground</title>
|
||||
<script src="/assets/theme.js"></script>
|
||||
<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 type="module" src="/assets/playground.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="playground-app">
|
||||
<header class="playground-header">
|
||||
<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>
|
||||
</a>
|
||||
<div class="header-divider"></div>
|
||||
<div class="project-name">
|
||||
<input
|
||||
id="project-title"
|
||||
maxlength="80"
|
||||
value="Three-light icosphere"
|
||||
aria-label="Playground title"
|
||||
/>
|
||||
<span id="dirty-indicator" title="Unsaved changes"></span>
|
||||
<span id="revision-label">Draft</span>
|
||||
</div>
|
||||
<nav class="playground-nav" aria-label="Playground links">
|
||||
<a href="/docs">Docs</a>
|
||||
<a href="https://git.heaust.org/heaust/yawn">Source</a>
|
||||
</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>
|
||||
<span aria-hidden="true">↗</span> Share
|
||||
</button>
|
||||
<button id="save-button" class="tool-button" type="button">
|
||||
<span aria-hidden="true">◇</span> Save
|
||||
</button>
|
||||
<button id="run-button" class="run-button" type="button">
|
||||
<span aria-hidden="true">▶</span> Run <kbd>Ctrl ↵</kbd>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="playground-tools">
|
||||
<div class="tool-group">
|
||||
<button id="snippets-button" type="button">
|
||||
<span class="braces">{ }</span> Snippets
|
||||
</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 class="tool-context">
|
||||
<span class="language-dot"></span>
|
||||
<span>TypeScript</span>
|
||||
<span class="context-divider"></span>
|
||||
<span id="type-status">Starting language service…</span>
|
||||
</div>
|
||||
<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="fullscreen-button"
|
||||
type="button"
|
||||
aria-label="Toggle fullscreen"
|
||||
>
|
||||
<span aria-hidden="true">⛶</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workbench" id="workbench">
|
||||
<section class="editor-pane" aria-label="TypeScript editor">
|
||||
<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>
|
||||
</div>
|
||||
<div id="editor" class="editor">
|
||||
<div class="editor-loading">Loading TypeScript editor…</div>
|
||||
</div>
|
||||
<footer class="editor-status">
|
||||
<span id="cursor-status">Ln 1, Col 1</span>
|
||||
<span>Spaces: 2</span>
|
||||
<span>UTF-8</span>
|
||||
<span class="status-spacer"></span>
|
||||
<span id="editor-diagnostics"><i></i> No problems</span>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<div class="stage-toolbar">
|
||||
<div class="stage-tabs">
|
||||
<button type="button" class="active">Preview</button>
|
||||
</div>
|
||||
<div class="stage-meta">
|
||||
<span id="run-status"><i></i> Ready</span>
|
||||
<span class="stage-divider"></span>
|
||||
<span id="fps-status">0 FPS</span>
|
||||
<span class="stage-divider"></span>
|
||||
<span id="resolution-status">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview" id="preview">
|
||||
<canvas id="canvas" aria-label="Yawn WebGPU preview"></canvas>
|
||||
<div class="preview-message" id="preview-message">
|
||||
<span class="preview-message-icon">Y</span>
|
||||
<strong>Preparing your scene</strong>
|
||||
<small>The first run compiles the render graph.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inspector" id="inspector">
|
||||
<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
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
<div
|
||||
class="profile-panel inspector-panel"
|
||||
id="profile-panel"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div class="profile-empty" id="profile-empty">
|
||||
<span class="pulse-icon">⌁</span>
|
||||
<div>
|
||||
<strong>Waiting for GPU timestamps</strong
|
||||
><small>Run the scene to inspect its physical passes.</small>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Physical pass</th>
|
||||
<th>GPU time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="profile-passes"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<dialog id="snippets-dialog" class="snippets-dialog">
|
||||
<div class="dialog-heading">
|
||||
<div>
|
||||
<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 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="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>
|
||||
</dialog>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user