Optimize render graph and add GPU profiling

Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-20 11:48:06 +00:00
co-authored by heaust
parent ded70a3cb4
commit 000bfd63bf
22 changed files with 1622 additions and 203 deletions
+3
View File
@@ -4,6 +4,7 @@ export type GraphBuffer = {
id: string;
array: string;
usage?: string[];
sync?: "frame" | "loadout";
};
export type GraphTexture = {
@@ -18,6 +19,8 @@ export type GraphSampler = {
id: string;
magFilter?: "nearest" | "linear";
minFilter?: "nearest" | "linear";
addressModeU?: "clamp-to-edge" | "repeat" | "mirror-repeat";
addressModeV?: "clamp-to-edge" | "repeat" | "mirror-repeat";
};
export type GraphBinding = {
+290 -43
View File
@@ -91,7 +91,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
clusters[3] = bitcast<u32>(light.b);
}`;
const forwardShader = /* wgsl */ `
const basicForwardShader = /* wgsl */ `
struct Accent { color: vec4<f32> }
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@@ -153,6 +153,103 @@ fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(color, base.a);
}`;
function pbrShader(mask: number) {
const baseTexture = mask & 1;
const materialTexture = mask & 2;
const normalTexture = mask & 4;
return /* wgsl */ `
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) world: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) tangent: vec4<f32>,
@location(4) @interpolate(flat) mesh: u32,
@location(5) @interpolate(flat) material: u32,
}
@group(0) @binding(0) var<storage, read> clusters: array<u32>;
@group(0) @binding(1) var<uniform> accent: vec4<f32>;
@group(0) @binding(2) var<storage, read> positions: array<vec4<f32>>;
@group(0) @binding(3) var<storage, read> quaternions: array<vec4<f32>>;
@group(0) @binding(4) var<storage, read> scales: array<vec4<f32>>;
@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> cameras: array<vec4<f32>>;
${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""}
${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d<f32>;" : ""}
${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d<f32>;" : ""}
${normalTexture ? "@group(1) @binding(3) var normalTexture: texture_2d<f32>;" : ""}
fn rotate(q: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.w * value);
}
@vertex
fn vertex(
@location(0) point: vec3<f32>,
@location(1) localNormal: vec3<f32>,
@location(2) uv: vec2<f32>,
${normalTexture ? "@location(3) localTangent: vec4<f32>," : ""}
@builtin(instance_index) packed: u32,
) -> VertexOutput {
let instance = packed & 65535u;
let scale = scales[instance].xyz;
let world = rotate(quaternions[instance], point * scale) + positions[instance].xyz;
var clip = vec4<f32>(world, 1.0);
if (cameras[2].w != 0.0) {
let cameraNode = u32(cameras[1].x);
let inverse = vec4<f32>(-quaternions[cameraNode].xyz, quaternions[cameraNode].w);
let view = rotate(inverse, world - positions[cameraNode].xyz);
if (cameras[1].y == 1.0) {
let size = max(cameras[1].z, 0.0001);
clip = vec4<f32>(view.x / (size * cameras[0].y * 0.5), view.y / (size * 0.5), -view.z / cameras[0].w, 1.0);
} else {
let focal = 1.0 / tan(cameras[0].x * 0.5);
let depth = (-view.z * cameras[0].w - cameras[0].z * cameras[0].w) / (cameras[0].w - cameras[0].z);
clip = vec4<f32>(view.x * focal / cameras[0].y, view.y * focal, depth, -view.z);
}
}
var output: VertexOutput;
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), clip, meshInfo[instance * 4u + 2u] != 0u);
output.world = world;
output.normal = normalize(rotate(quaternions[instance], localNormal / scale));
output.uv = uv;
output.tangent = ${normalTexture ? "vec4(normalize(rotate(quaternions[instance], localTangent.xyz * scale)), localTangent.w)" : "vec4(1.0, 0.0, 0.0, 1.0)"};
output.mesh = instance;
output.material = packed >> 16u;
return output;
}
@fragment
fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
let fallback = meshInfo[input.mesh * 4u + 1u];
let material = select(fallback, input.material - 1u, input.material != 0u);
let factor = materials[material * 3u];
let properties = materials[material * 3u + 1u];
let extra = materials[material * 3u + 2u];
let base = factor * ${baseTexture ? "textureSample(baseTexture, materialSampler, input.uv)" : "vec4(1.0)"};
let packedMaterial = ${materialTexture ? "textureSample(materialTexture, materialSampler, input.uv)" : "vec4(1.0)"};
let metallic = clamp(properties.x * ${materialTexture ? "packedMaterial.b" : "1.0"}, 0.0, 1.0);
let roughness = clamp(properties.y * ${materialTexture ? "packedMaterial.g" : "1.0"}, 0.04, 1.0);
var normal = normalize(input.normal);
${normalTexture ? "let tangent = normalize(input.tangent.xyz); let bitangent = normalize(cross(normal, tangent)) * input.tangent.w; let mapped = textureSample(normalTexture, materialSampler, input.uv).xyz * 2.0 - 1.0; normal = normalize(mat3x3<f32>(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));" : ""}
let cameraNode = u32(cameras[1].x);
let view = normalize(positions[cameraNode].xyz - input.world);
let lightDirection = normalize(vec3<f32>(0.4, 0.7, 0.6));
let halfVector = normalize(lightDirection + view);
let nDotL = max(dot(normal, lightDirection), 0.0);
let nDotH = max(dot(normal, halfVector), 0.0);
let f0 = mix(vec3(0.04), base.rgb, metallic);
let specular = f0 * pow(nDotH, max(2.0, 2.0 / (roughness * roughness) - 2.0));
let 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 emissive = vec3(properties.z, properties.w, extra.x);
return vec4((base.rgb * ambient + diffuse + specular * nDotL + emissive) * accent.a, base.a);
}`;
}
const emptyForwardShader = /* wgsl */ `
struct Accent { color: vec4<f32> }
@group(0) @binding(0) var<uniform> accent: Accent;
@@ -257,10 +354,13 @@ export class Scene {
constructor(
canvas: HTMLCanvasElement,
options: { arenaBytes?: number; fps?: number; hdr?: boolean } = {},
options: { arenaBytes?: number; debug?: boolean; fps?: number; hdr?: boolean } = {},
) {
this.hdr = options.hdr ?? true;
this.core = new YawnCore(canvas, { arenaBytes: options.arenaBytes });
this.core = new YawnCore(canvas, {
arenaBytes: options.arenaBytes,
debug: options.debug,
});
this.ready = this.#initialize(options.fps ?? 60);
}
@@ -635,7 +735,23 @@ export class Scene {
usage: ["render", "sampled"],
transient: false,
});
addTexture({
id: "depth",
format: "depth24plus",
size: ["canvas", "canvas", 1],
usage: ["render"],
transient: true,
});
addSampler({ id: "linear", magFilter: "linear", minFilter: "linear" });
addSampler({
id: "material-linear",
magFilter: "linear",
minFilter: "linear",
addressModeU: "repeat",
addressModeV: "repeat",
});
for (const { number: _, source: __, ...texture } of this.#textures.values())
addTexture(texture);
let previous = computeIds.length ? computeIds : ["cluster-lights"];
if (!renderedMeshes.length) {
@@ -665,22 +781,7 @@ export class Scene {
["cameras", "cameras"],
])
addBuffer({ id, array, usage: ["storage"] });
renderPipelines.push({
id: "forward-pbr",
code: forwardShader,
vertex: {
entry: "vertex",
buffers: [
{
arrayStride: 16,
attributes: [
{ format: "float32x3", offset: 0, shaderLocation: 0 },
],
},
],
},
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
});
const forwardPipelines = new Set<string>();
let firstRender = true;
for (const mesh of renderedMeshes) {
const vertex = `geometry-${mesh.geometryId}-positions`;
@@ -688,6 +789,25 @@ export class Scene {
id: vertex,
array: `geometry.${mesh.geometryId}.positions`,
usage: ["vertex"],
sync: "loadout",
});
const normal = `geometry-${mesh.geometryId}-normals`;
const uv = `geometry-${mesh.geometryId}-uvs`;
const tangent = `geometry-${mesh.geometryId}-tangents`;
const hasNormals = !!this.geometryData(mesh.geometryId, "normals");
const hasUvs = !!this.geometryData(mesh.geometryId, "uvs");
const hasTangents = !!this.geometryData(mesh.geometryId, "tangents");
for (const [present, id, kind] of [
[hasNormals, normal, "normals"],
[hasUvs, uv, "uvs"],
[hasTangents, tangent, "tangents"],
] as const)
if (present)
addBuffer({
id,
array: `geometry.${mesh.geometryId}.${kind}`,
usage: ["vertex"],
sync: "loadout",
});
const indexed = mesh.indexCount > 0;
if (indexed)
@@ -695,6 +815,7 @@ export class Scene {
id: `geometry-${mesh.geometryId}-indices`,
array: `geometry.${mesh.geometryId}.indices`,
usage: ["index"],
sync: "loadout",
});
const draws =
indexed && mesh.faceMaterials.size
@@ -717,8 +838,103 @@ export class Scene {
];
for (const draw of draws) {
const id = `forward-${mesh.id}-${draw.face}`;
if (mesh.id > 65535 || (draw.material ?? 0) > 65534)
const material =
draw.material ?? Number(this.array("meshInfo").row(mesh.id)[1]);
if (mesh.id > 65535 || material > 65534)
throw new RangeError("Scene handle limit");
const pointers = this.array("materialTextures").row(material);
const texture = (lane: number) =>
pointers[lane]
? this.#textures.get(Number(pointers[lane]) - 1)
: undefined;
const baseTexture = texture(0);
const materialTexture = texture(1);
const normalTexture = hasTangents ? texture(2) : undefined;
const detailed = hasNormals && hasUvs;
const mask = detailed
? (baseTexture ? 1 : 0) |
(materialTexture ? 2 : 0) |
(normalTexture ? 4 : 0)
: 0;
const pipeline = detailed ? `forward-pbr-${mask}` : "forward-basic";
if (!forwardPipelines.has(pipeline)) {
forwardPipelines.add(pipeline);
renderPipelines.push({
id: pipeline,
code: detailed ? pbrShader(mask) : basicForwardShader,
vertex: {
entry: "vertex",
buffers: detailed
? [
{
arrayStride: 16,
attributes: [
{
format: "float32x3",
offset: 0,
shaderLocation: 0,
},
],
},
{
arrayStride: 16,
attributes: [
{
format: "float32x3",
offset: 0,
shaderLocation: 1,
},
],
},
{
arrayStride: 16,
attributes: [
{
format: "float32x2",
offset: 0,
shaderLocation: 2,
},
],
},
...(normalTexture
? [
{
arrayStride: 16,
attributes: [
{
format: "float32x4",
offset: 0,
shaderLocation: 3,
},
],
},
]
: []),
]
: [
{
arrayStride: 16,
attributes: [
{
format: "float32x3",
offset: 0,
shaderLocation: 0,
},
],
},
],
},
fragment: {
entry: "fragment",
targets: [{ format: hdrFormat }],
},
depthStencil: {
format: "depth24plus",
depth_write_enabled: true,
depth_compare: "less",
},
});
}
const instance =
(mesh.id +
(draw.material === undefined
@@ -728,9 +944,10 @@ export class Scene {
passes.push({
id,
type: "render",
pipeline: "forward-pbr",
pipeline,
after: previous,
bindings: [
...[
"clusters",
"accent",
"node-positions",
@@ -739,7 +956,44 @@ export class Scene {
"mesh-info",
"materials",
"cameras",
].map((resource, binding) => ({ group: 0, binding, resource })),
].map((resource, binding) => ({
group: 0,
binding,
resource,
})),
...(detailed && mask
? [
{ group: 1, binding: 0, resource: "material-linear" },
...(baseTexture
? [
{
group: 1,
binding: 1,
resource: baseTexture.id,
},
]
: []),
...(materialTexture
? [
{
group: 1,
binding: 2,
resource: materialTexture.id,
},
]
: []),
...(normalTexture
? [
{
group: 1,
binding: 3,
resource: normalTexture.id,
},
]
: []),
]
: []),
],
color: [
{
resource: "hdr",
@@ -748,7 +1002,20 @@ export class Scene {
: { load: "load" }),
},
],
vertexBuffers: [{ slot: 0, resource: vertex }],
depth: {
resource: "depth",
...(firstRender ? { clear: 1 } : { load: "load" }),
},
vertexBuffers: [
{ slot: 0, resource: vertex },
...(detailed
? [
{ slot: 1, resource: normal },
{ slot: 2, resource: uv },
...(normalTexture ? [{ slot: 3, resource: tangent }] : []),
]
: []),
],
...(indexed
? {
indexBuffer: {
@@ -827,26 +1094,6 @@ export class Scene {
previous = [pipeline];
}
for (const { number, source: _, ...texture } of this.#textures.values()) {
addTexture(texture);
const id = `retain-texture-${number}`;
computePipelines.push({
id,
code: "@group(0) @binding(0) var source: texture_2d<f32>; @group(0) @binding(1) var<storage, read_write> output: array<u32>; @compute @workgroup_size(1) fn main() { output[1] = textureDimensions(source).x; }",
entry: "main",
});
passes.push({
id,
type: "compute",
pipeline: id,
after: ["cluster-lights"],
dispatch: [1, 1, 1],
bindings: [
{ group: 0, binding: 0, resource: texture.id },
{ group: 0, binding: 1, resource: "clusters" },
],
});
}
const toneMap = String(
[...this.#effects.values()].find(
(effect) => effect.kind === "colorGrading",
+31 -2
View File
@@ -1,6 +1,7 @@
import { Mesh } from "../Mesh";
import type { Scene } from "../Scene";
import { PBRMaterial } from "../materials/PBRMaterial";
import { Texture } from "../materials/Texture";
let worker: Worker | undefined;
let nextRequest = 1;
@@ -42,9 +43,31 @@ export async function importGltf(scene: Scene, url: string | URL) {
nodes: result.primitives.length,
materials: result.materials.length,
});
return scene.batchGraphUpdates(async () => {
let textures: Texture[] = [];
const imported = await scene.batchGraphUpdates(async () => {
const srgb = new Set<number>();
for (const material of result.materials) {
if (material.baseColorTexture >= 0) srgb.add(material.baseColorTexture);
if (material.emissiveTexture >= 0) srgb.add(material.emissiveTexture);
}
textures = result.textures.map(
({ image }: { image: ImageBitmap }, index: number) =>
new Texture(scene, {
source: image,
format: srgb.has(index) ? "rgba8unorm-srgb" : "rgba8unorm",
}),
);
await Promise.all(textures.map((texture: Texture) => texture.ready));
const materials = result.materials.map(
(options: any) => new PBRMaterial(scene, options),
(options: any) =>
new PBRMaterial(scene, {
...options,
baseColorTexture: textures[options.baseColorTexture],
metallicRoughnessTexture:
textures[options.metallicRoughnessTexture],
normalTexture: textures[options.normalTexture],
emissiveTexture: textures[options.emissiveTexture],
}),
);
await Promise.all(materials.map((material: PBRMaterial) => material.ready));
const meshes: Mesh[] = result.primitives.map(
@@ -67,4 +90,10 @@ export async function importGltf(scene: Scene, url: string | URL) {
await Promise.all(meshes.map((mesh) => mesh.ready));
return meshes;
});
await Promise.all(
result.textures.map((_: unknown, index: number) =>
scene.core.deleteTexture(textures[index].resource),
),
);
return imported;
}
+26 -1
View File
@@ -144,6 +144,26 @@ async function load(url: string) {
return new Uint8Array(await result.arrayBuffer());
}),
);
const imageBlobs = await Promise.all(
(document.images ?? []).map(async (image: any) => {
if (image.bufferView !== undefined) {
const view = document.bufferViews[image.bufferView];
const bytes = buffers[view.buffer];
const start = view.byteOffset ?? 0;
return new Blob([bytes.slice(start, start + view.byteLength)], {
type: image.mimeType,
});
}
const result = await fetch(new URL(image.uri, url));
if (!result.ok) throw new Error(`HTTP_${result.status}`);
return result.blob();
}),
);
const textures = await Promise.all(
(document.textures ?? []).map(async (texture: any) => ({
image: await createImageBitmap(imageBlobs[texture.source]),
})),
);
const accessor = (id: number, integer = false) => {
const source = document.accessors[id];
@@ -199,6 +219,10 @@ async function load(url: string) {
roughness: pbr.roughnessFactor ?? 0.7,
emissive: material.emissiveFactor ?? [0, 0, 0],
alphaCutoff: material.alphaCutoff ?? 0.5,
baseColorTexture: pbr.baseColorTexture?.index ?? -1,
metallicRoughnessTexture: pbr.metallicRoughnessTexture?.index ?? -1,
normalTexture: material.normalTexture?.index ?? -1,
emissiveTexture: material.emissiveTexture?.index ?? -1,
};
});
const primitives: any[] = [];
@@ -264,7 +288,7 @@ async function load(url: string) {
if (!nodes.length)
for (let id = 0; id < (document.meshes ?? []).length; id++)
emitMesh(id, {});
return { materials, primitives };
return { materials, primitives, textures };
}
addEventListener("message", async ({ data }) => {
@@ -275,6 +299,7 @@ addEventListener("message", async ({ data }) => {
.map((name) => primitive[name]?.buffer)
.filter(Boolean),
);
transfers.push(...result.textures.map((texture: any) => texture.image));
(postMessage as any)({ request: data.request, result }, transfers);
} catch (error) {
postMessage({
+8 -1
View File
@@ -23,7 +23,14 @@ export class PBRMaterial {
constructor(scene: Scene, options: PBRMaterialOptions = {}) {
this.scene = scene;
this.ready = scene.allocateMaterial().then((id) => {
const textures = [
options.baseColorTexture,
options.metallicRoughnessTexture,
options.normalTexture,
options.emissiveTexture,
].filter((texture): texture is Texture => texture !== undefined);
this.ready = Promise.all(textures.map((texture) => texture.ready)).then(async () => {
const id = await scene.allocateMaterial();
this.id = id;
const color = Array.from(options.baseColor ?? [1, 1, 1, 1]);
const emissive = Array.from(options.emissive ?? [0, 0, 0]);
+27 -7
View File
@@ -13,7 +13,7 @@ let nextTextureName = 1;
/** A graph texture resource handle; image decoding/upload policy remains outside core. */
export class Texture {
readonly scene: Scene;
readonly id: number;
id = -1;
readonly resource: string;
readonly source?: string | ImageBitmap;
readonly ready: Promise<void>;
@@ -21,24 +21,44 @@ export class Texture {
constructor(scene: Scene, options: TextureOptions = {}) {
this.scene = scene;
this.resource = `texture-${nextTextureName++}`;
const resource = `texture-${nextTextureName++}`;
this.resource = resource;
this.source = options.source;
this.ready = (async () => {
const image =
typeof options.source === "string"
? await createImageBitmap(await (await fetch(options.source)).blob())
: options.source;
const registration = scene.registerTexture({
id: this.resource,
source: options.source,
size: options.size ?? [1, 1, 1],
id: resource,
source: image,
size:
options.size ??
(image instanceof ImageBitmap
? [image.width, image.height, 1]
: [1, 1, 1]),
format: options.format ?? "rgba8unorm",
usage: [...new Set([...(options.usage ?? ["copyDst"]), "sampled"])],
usage: [
...new Set([
...(options.usage ?? ["copyDst"]),
"sampled",
...(image instanceof ImageBitmap ? ["render"] : []),
]),
],
transient: options.transient ?? false,
});
this.id = registration.number;
this.ready = registration.ready;
await registration.ready;
if (image instanceof ImageBitmap)
await scene.core.uploadTexture(resource, image);
})();
}
async dispose() {
await this.ready;
if (this.#disposed) return;
this.#disposed = true;
await this.scene.core.deleteTexture(this.resource);
await this.scene.unregisterTexture(this.id);
}
}
+1 -1
View File
@@ -15,5 +15,5 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["OffscreenCanvas"] }
web-sys = { version = "0.3", features = ["ImageBitmap", "OffscreenCanvas"] }
wgpu = { version = "26", default-features = false, features = ["serde", "webgpu", "wgsl"] }
+32 -2
View File
@@ -46,9 +46,10 @@ export class YawnCore {
#buffer;
#arrays = new Map();
#pending = new Map();
#profileListeners = new Set();
#next = 1;
constructor(canvas, { arenaBytes = 64 * 1024 * 1024, workerFactory } = {}) {
constructor(canvas, { arenaBytes = 64 * 1024 * 1024, debug = false, workerFactory } = {}) {
if (!canvas) throw new TypeError("canvas is required");
this.#worker = workerFactory?.() ?? new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
@@ -59,12 +60,13 @@ export class YawnCore {
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR"));
this.#worker.start?.();
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(result => {
this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(async result => {
this.#buffer = result.buffer;
for (const descriptor of result.rows) this.#arrays.set(
descriptor.name,
new SharedRows(this.#buffer, descriptor),
);
if (debug) await this.#request("set-profiler", { enabled: true });
});
}
@@ -117,6 +119,18 @@ export class YawnCore {
return this.#request("switch-loadout", { id });
}
async uploadTexture(name, image) {
await this.ready;
if (typeof name !== "string" || !(image instanceof ImageBitmap))
throw new TypeError("TEXTURE_SOURCE");
return this.#request("upload-texture", { name, image }, [image]);
}
async deleteTexture(name) {
await this.ready;
return this.#request("delete-texture", { name });
}
async play() {
await this.ready;
return this.#request("play");
@@ -132,6 +146,17 @@ export class YawnCore {
return this.#request("set-fps", { fps });
}
async setProfiler(enabled) {
await this.ready;
return this.#request("set-profiler", { enabled: Boolean(enabled) });
}
onProfile(listener) {
if (typeof listener !== "function") throw new TypeError("PROFILE_LISTENER");
this.#profileListeners.add(listener);
return () => this.#profileListeners.delete(listener);
}
array(name) {
const array = this.#arrays.get(name);
if (!array) throw new Error(`UNKNOWN_ARRAY: ${name}`);
@@ -147,6 +172,10 @@ export class YawnCore {
}
#message(message) {
if (message?.type === "profile") {
for (const listener of this.#profileListeners) listener(message.stats);
return;
}
const pending = this.#pending.get(message?.request);
if (!pending) return;
this.#pending.delete(message.request);
@@ -161,6 +190,7 @@ export class YawnCore {
dispose() {
this.#fail("DISPOSED");
this.#profileListeners.clear();
this.#worker.terminate();
}
}
+29
View File
@@ -174,6 +174,21 @@ impl Core {
.map_err(|error| JsError::new(&error))
}
pub fn upload_texture(&self, name: String, image: web_sys::ImageBitmap) -> Result<(), JsError> {
let gpu = self.gpu.borrow();
let gpu = gpu
.as_ref()
.ok_or_else(|| JsError::new("WEBGPU_UNINITIALIZED"))?;
self.store
.borrow_mut()
.upload_texture(name, image, gpu)
.map_err(|error| JsError::new(&error))
}
pub fn delete_texture(&self, name: &str) {
self.store.borrow_mut().delete_texture(name);
}
pub fn play(&self) {
self.render.play();
}
@@ -185,4 +200,18 @@ impl Core {
pub fn set_fps(&self, fps: u32) -> Result<(), JsError> {
self.render.set_fps(fps).map_err(JsError::new)
}
pub fn set_profiler(&self, enabled: bool) -> bool {
let supported = self
.gpu
.borrow()
.as_ref()
.is_some_and(|gpu| gpu.timestamp_queries);
self.render.set_profiling(enabled && supported);
supported
}
pub fn take_profile(&self) -> Option<String> {
self.render.take_profile()
}
}
+264 -15
View File
@@ -1,11 +1,13 @@
use std::collections::{BTreeMap, HashMap};
use std::num::NonZeroU64;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::gpu::Wgpu;
use crate::graph::{Binding, Extent, Pass, RenderGraph, RenderPipeline, Texture};
use crate::graph::{Binding, Execution, Extent, Pass, RenderGraph, RenderPipeline, Texture};
use crate::render_data::RenderData;
pub struct GpuResources {
@@ -16,28 +18,59 @@ 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 struct GpuBuffer {
pub buffer: wgpu::Buffer,
pub source: String,
pub sync_each_frame: bool,
}
#[derive(Clone)]
pub struct GpuTexture {
pub _texture: wgpu::Texture,
pub texture: wgpu::Texture,
pub view: wgpu::TextureView,
key: String,
uploaded: bool,
}
pub enum GpuPass {
Render(wgpu::RenderBundle),
Render {
label: String,
first: usize,
last: usize,
bundle: wgpu::RenderBundle,
},
Compute {
label: String,
pass: usize,
pipeline: wgpu::ComputePipeline,
bind_groups: Vec<(u32, wgpu::BindGroup)>,
},
}
impl GpuResources {
pub fn activate(graph: &RenderGraph, gpu: &Wgpu, data: &RenderData) -> Result<Self, String> {
pub fn activate(
graph: &RenderGraph,
gpu: &Wgpu,
data: &RenderData,
previous: Option<&Self>,
) -> Result<Self, String> {
let mut buffers = HashMap::new();
for source in &graph.resources.buffers {
let rows = data.rows(&source.array).ok_or("GRAPH_ARRAY_UNKNOWN")?;
@@ -54,6 +87,7 @@ impl GpuResources {
GpuBuffer {
buffer,
source: source.array.clone(),
sync_each_frame: source.sync == "frame",
},
);
}
@@ -65,12 +99,32 @@ impl GpuResources {
let physical = match physical_slots.get(&source.slot) {
Some(&physical) => physical,
None => {
let key = source.key()?;
if !source.transient {
if let Some(texture) = previous
.and_then(|resources| {
resources
.texture_slots
.get(&source.id)
.map(|slot| &resources.textures[*slot])
})
.filter(|texture| texture.key == key)
{
let physical = textures.len();
textures.push(texture.clone());
physical_slots.insert(source.slot, physical);
texture_slots.insert(source.id.clone(), physical);
continue;
}
}
let descriptor = texture_descriptor(source, gpu.width, gpu.height)?;
let texture = gpu.device.create_texture(&descriptor);
let physical = textures.len();
textures.push(GpuTexture {
view: texture.create_view(&wgpu::TextureViewDescriptor::default()),
_texture: texture,
texture,
key,
uploaded: false,
});
physical_slots.insert(source.slot, physical);
physical
@@ -121,6 +175,30 @@ 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,
@@ -129,11 +207,27 @@ impl GpuResources {
render_pipelines,
compute_pipelines,
passes: Vec::new(),
profiler,
};
for pass in &graph.passes {
let compiled = match pass.kind.as_str() {
"render" => GpuPass::Render(resources.render_bundle(graph, pass, gpu)?),
"compute" => {
for execution in &graph.executions {
let compiled = match execution {
Execution::Render(passes) => GpuPass::Render {
label: if passes.len() == 1 {
graph.passes[passes[0]].id.clone()
} else if passes
.iter()
.all(|index| graph.passes[*index].id.starts_with("forward-"))
{
format!("Forward ({} draws)", passes.len())
} else {
format!("Render ({} draws)", passes.len())
},
first: passes[0],
last: *passes.last().unwrap(),
bundle: resources.render_bundle(graph, passes, gpu)?,
},
Execution::Compute(index) => {
let pass = &graph.passes[*index];
let pipeline = resources
.compute_pipelines
.get(&pass.pipeline)
@@ -145,11 +239,12 @@ impl GpuResources {
gpu,
)?;
GpuPass::Compute {
label: pass.id.clone(),
pass: *index,
pipeline,
bind_groups,
}
}
_ => return Err("GRAPH_PASS".into()),
};
resources.passes.push(compiled);
}
@@ -163,16 +258,146 @@ impl GpuResources {
.map(|texture| &texture.view)
}
pub fn upload_texture(
&mut self,
id: &str,
image: &web_sys::ImageBitmap,
gpu: &Wgpu,
) -> Result<(), String> {
let texture = self
.texture_slots
.get(id)
.and_then(|slot| self.textures.get_mut(*slot))
.ok_or("GRAPH_TEXTURE_UNKNOWN")?;
gpu.queue.copy_external_image_to_texture(
&wgpu::CopyExternalImageSourceInfo {
source: wgpu::ExternalImageSource::ImageBitmap(image.clone()),
origin: wgpu::Origin2d::ZERO,
flip_y: false,
},
wgpu::TexelCopyTextureInfo {
texture: &texture.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
}
.to_tagged(wgpu::PredefinedColorSpace::Srgb, false),
wgpu::Extent3d {
width: image.width(),
height: image.height(),
depth_or_array_layers: 1,
},
);
texture.uploaded = true;
Ok(())
}
pub fn needs_upload(&self, id: &str) -> bool {
self.texture_slots
.get(id)
.and_then(|slot| self.textures.get(*slot))
.is_some_and(|texture| !texture.uploaded)
}
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));
let callback = state.clone();
profiler
.readback
.map_async(wgpu::MapMode::Read, .., move |result| {
callback.store(if result.is_ok() { 1 } else { 2 }, Ordering::Release);
});
Some(ProfileMap {
readback: profiler.readback.clone(),
state,
labels: self
.passes
.iter()
.map(|pass| pass.label().to_owned())
.collect(),
timestamp_period,
})
}
}
impl ProfileMap {
pub fn state(&self) -> u8 {
self.state.load(Ordering::Acquire)
}
pub fn read(self) -> Vec<(String, f64)> {
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
.labels
.into_iter()
.zip(values.chunks_exact(2))
.map(|(label, timestamps)| {
(
label,
timestamps[1].saturating_sub(timestamps[0]) as f64
* f64::from(self.timestamp_period)
/ 1_000_000.0,
)
})
.collect();
drop(bytes);
self.readback.unmap();
profile
}
}
impl GpuResources {
fn render_bundle(
&self,
graph: &RenderGraph,
pass: &Pass,
passes: &[usize],
gpu: &Wgpu,
) -> Result<wgpu::RenderBundle, String> {
let pipeline = self
.render_pipelines
.get(&pass.pipeline)
.ok_or("GRAPH_PIPELINE")?;
let pass = &graph.passes[passes[0]];
let declaration = graph
.pipelines
.render
@@ -204,12 +429,27 @@ impl GpuResources {
sample_count: multisample(&declaration.multisample)?.count,
multiview: None,
});
let mut previous_pipeline = None;
let mut previous_bindings: Option<&[Binding]> = None;
for index in passes {
let pass = &graph.passes[*index];
let pipeline = self
.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)?
{
encoder.set_bind_group(group, &bind_group, &[]);
}
previous_bindings = Some(&pass.bindings);
}
for binding in &pass.vertex_buffers {
let buffer = &self
.buffers
@@ -239,6 +479,7 @@ impl GpuResources {
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
);
}
}
Ok(encoder.finish(&wgpu::RenderBundleDescriptor {
label: Some(&pass.id),
}))
@@ -290,6 +531,14 @@ impl GpuResources {
}
}
impl GpuPass {
fn label(&self) -> &str {
match self {
Self::Render { label, .. } | Self::Compute { label, .. } => label,
}
}
}
fn create_render_pipeline(
source: &RenderPipeline,
gpu: &Wgpu,
+100 -3
View File
@@ -12,6 +12,14 @@ pub struct RenderGraph {
#[serde(default)]
pub pipelines: PipelineDeclarations,
pub passes: Vec<Pass>,
#[serde(skip)]
pub executions: Vec<Execution>,
}
#[derive(Clone)]
pub enum Execution {
Compute(usize),
Render(Vec<usize>),
}
#[derive(Clone, Default, Deserialize)]
@@ -30,6 +38,8 @@ pub struct Buffer {
pub array: String,
#[serde(default)]
pub usage: Vec<String>,
#[serde(default = "frame_sync")]
pub sync: String,
}
#[derive(Clone, Deserialize, Serialize)]
@@ -186,7 +196,7 @@ pub struct Pass {
pub dispatch: [u32; 3],
}
#[derive(Clone, Deserialize)]
#[derive(Clone, Deserialize, PartialEq, Eq)]
pub struct Binding {
#[serde(default)]
pub group: u32,
@@ -318,7 +328,9 @@ impl RenderGraph {
for pass in &mut self.passes {
pass.dependencies = pass.after.iter().map(|id| sorted_ids[id]).collect();
}
self.plan_resources()
self.plan_resources()?;
self.plan_execution();
Ok(())
}
fn plan_resources(&mut self) -> Result<(), &'static str> {
@@ -391,6 +403,80 @@ impl RenderGraph {
self.validate_ids()
}
fn plan_execution(&mut self) {
let mut executions = Vec::new();
for index in 0..self.passes.len() {
let merge = executions.last().is_some_and(|execution| match execution {
Execution::Render(passes) => self.can_merge_render(*passes.last().unwrap(), index),
Execution::Compute(_) => false,
});
if merge {
let Some(Execution::Render(passes)) = executions.last_mut() else {
unreachable!()
};
passes.push(index);
} else if self.passes[index].kind == "render" {
executions.push(Execution::Render(vec![index]));
} else {
executions.push(Execution::Compute(index));
}
}
self.executions = executions;
}
fn can_merge_render(&self, previous: usize, next: usize) -> bool {
let previous = &self.passes[previous];
let next = &self.passes[next];
if next.kind != "render"
|| previous.color.len() != next.color.len()
|| self.sample_count(&previous.pipeline) != self.sample_count(&next.pipeline)
|| previous
.color
.iter()
.zip(&next.color)
.any(|(previous, next)| {
previous.resource != next.resource
|| previous.store != "store"
|| next.load != "load"
})
{
return false;
}
let same_depth = match (&previous.depth, &next.depth) {
(None, None) => true,
(Some(previous), Some(next)) => {
previous.resource == next.resource
&& previous.store == "store"
&& next.load == "load"
}
_ => false,
};
same_depth
&& !next.bindings.iter().any(|binding| {
next.color
.iter()
.any(|attachment| attachment.resource == binding.resource)
|| next
.depth
.as_ref()
.is_some_and(|attachment| attachment.resource == binding.resource)
})
}
fn sample_count(&self, pipeline: &str) -> Option<u64> {
self.pipelines
.render
.iter()
.find(|value| value.id == pipeline)
.map(|value| {
value
.multisample
.get("count")
.and_then(Value::as_u64)
.unwrap_or(1)
})
}
fn validate_ids(&self) -> Result<(), &'static str> {
let mut resources = HashSet::new();
for id in self
@@ -405,6 +491,14 @@ impl RenderGraph {
return Err("GRAPH_RESOURCE");
}
}
if self
.resources
.buffers
.iter()
.any(|buffer| !matches!(buffer.sync.as_str(), "frame" | "loadout"))
{
return Err("GRAPH_BUFFER_SYNC");
}
let mut pipelines = HashSet::new();
for id in self
.pipelines
@@ -443,7 +537,7 @@ impl Pass {
}
impl Texture {
fn key(&self) -> Result<String, &'static str> {
pub(crate) fn key(&self) -> Result<String, &'static str> {
let mut usage = self.usage.clone();
usage.sort_unstable();
usage.dedup();
@@ -498,3 +592,6 @@ fn index_format() -> String {
fn dispatch() -> [u32; 3] {
[1, 1, 1]
}
fn frame_sync() -> String {
"frame".into()
}
+46 -2
View File
@@ -13,6 +13,7 @@ pub struct Loadout {
#[derive(Default)]
pub struct Store {
graphs: HashMap<String, RenderGraph>,
texture_sources: HashMap<String, web_sys::ImageBitmap>,
active: Option<Loadout>,
}
@@ -25,11 +26,44 @@ impl Store {
pub fn switch(&mut self, id: &str, gpu: &Wgpu, data: &RenderData) -> Result<(), String> {
let graph = self.graphs.get(id).ok_or("GRAPH_UNKNOWN")?.clone();
let resources = GpuResources::activate(&graph, gpu, data)?;
let mut resources = GpuResources::activate(
&graph,
gpu,
data,
self.active.as_ref().map(|active| &active.resources),
)?;
for (name, image) in &self.texture_sources {
if resources.needs_upload(name) {
resources.upload_texture(name, image, gpu)?;
}
}
self.active = Some(Loadout { graph, resources });
Ok(())
}
pub fn upload_texture(
&mut self,
name: String,
image: web_sys::ImageBitmap,
gpu: &Wgpu,
) -> Result<(), String> {
if let Some(active) = &mut self.active {
if active.resources.texture_slots.contains_key(&name) {
active.resources.upload_texture(&name, &image, gpu)?;
}
}
if let Some(previous) = self.texture_sources.insert(name, image) {
previous.close();
}
Ok(())
}
pub fn delete_texture(&mut self, name: &str) {
if let Some(image) = self.texture_sources.remove(name) {
image.close();
}
}
pub fn active_mut(&mut self) -> Option<&mut Loadout> {
self.active.as_mut()
}
@@ -62,7 +96,17 @@ impl Store {
return Ok(());
};
let graph = active.graph.clone();
let resources = GpuResources::activate(&graph, gpu, data)?;
let mut resources = GpuResources::activate(
&graph,
gpu,
data,
self.active.as_ref().map(|active| &active.resources),
)?;
for (name, image) in &self.texture_sources {
if resources.needs_upload(name) {
resources.upload_texture(name, image, gpu)?;
}
}
self.active = Some(Loadout { graph, resources });
Ok(())
}
+126 -18
View File
@@ -1,10 +1,12 @@
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use gloo_timers::future::TimeoutFuture;
use crate::gpu::Wgpu;
use crate::gpu_resource::GpuPass;
use crate::gpu_resource::{GpuPass, ProfileMap};
use crate::graph::{ColorAttachment, DepthAttachment};
use crate::render_data::RenderData;
use crate::store::{Loadout, Store};
@@ -16,6 +18,13 @@ pub struct RenderLoop {
frame: Cell<u32>,
elapsed: Cell<f64>,
last: Cell<f64>,
profiling: Cell<bool>,
profile: RefCell<Option<String>>,
}
struct Submission {
completed: Arc<AtomicBool>,
profile: Option<ProfileMap>,
}
impl RenderLoop {
@@ -27,6 +36,8 @@ impl RenderLoop {
frame: Cell::new(0),
elapsed: Cell::new(0.0),
last: Cell::new(js_sys::Date::now()),
profiling: Cell::new(false),
profile: RefCell::new(None),
}
}
@@ -47,6 +58,17 @@ impl RenderLoop {
Ok(())
}
pub fn set_profiling(&self, enabled: bool) {
self.profiling.set(enabled);
if !enabled {
self.profile.borrow_mut().take();
}
}
pub fn take_profile(&self) -> Option<String> {
self.profile.borrow_mut().take()
}
pub fn start(
self: &Rc<Self>,
gpu: Rc<RefCell<Option<Wgpu>>>,
@@ -71,11 +93,51 @@ impl RenderLoop {
data.update_info(delta as f32, frame, elapsed as f32, control.fps.get());
data.skip_render()
};
if !skip {
let submission = if !skip {
if let (Some(gpu), Some(loadout)) =
(gpu.borrow_mut().as_mut(), store.borrow_mut().active_mut())
{
let _ = gpu.render(loadout, &data.borrow());
gpu.render(loadout, &data.borrow(), control.profiling.get())
.ok()
.flatten()
} else {
None
}
} else {
None
};
if let Some(submission) = submission {
while !submission.completed.load(Ordering::Acquire) {
TimeoutFuture::new(0).await;
}
if let Some(profile) = submission.profile {
while profile.state() == 0 {
TimeoutFuture::new(0).await;
}
if profile.state() == 1 {
let passes = profile
.read()
.into_iter()
.map(|(name, milliseconds)| {
serde_json::json!({
"name": name,
"milliseconds": milliseconds,
})
})
.collect::<Vec<_>>();
let milliseconds = passes
.iter()
.filter_map(|pass| pass["milliseconds"].as_f64())
.sum::<f64>();
*control.profile.borrow_mut() = Some(
serde_json::json!({
"frame": frame,
"milliseconds": milliseconds,
"passes": passes,
})
.to_string(),
);
}
}
}
}
@@ -88,8 +150,16 @@ impl RenderLoop {
}
impl Wgpu {
fn render(&mut self, loadout: &mut Loadout, data: &RenderData) -> Result<(), String> {
fn render(
&mut self,
loadout: &mut Loadout,
data: &RenderData,
profile: bool,
) -> Result<Option<Submission>, String> {
for buffer in loadout.resources.buffers.values() {
if !buffer.sync_each_frame {
continue;
}
self.queue.write_buffer(
&buffer.buffer,
0,
@@ -102,7 +172,7 @@ impl Wgpu {
self.surface.configure(&self.device, &self.config);
self.surface.get_current_texture().map_err(|_| "SURFACE")?
}
Err(wgpu::SurfaceError::Timeout) => return Ok(()),
Err(wgpu::SurfaceError::Timeout) => return Ok(None),
Err(_) => return Err("SURFACE".into()),
};
let surface_view = output
@@ -113,15 +183,21 @@ impl Wgpu {
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("frame"),
});
for (pass, compiled) in loadout.graph.passes.iter().zip(&loadout.resources.passes) {
for (pass_index, compiled) in loadout.resources.passes.iter().enumerate() {
match compiled {
GpuPass::Compute {
pass,
pipeline,
bind_groups,
..
} => {
let pass = &loadout.graph.passes[*pass];
let timestamp_writes = profile
.then(|| loadout.resources.compute_timestamps(pass_index))
.flatten();
let mut command = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some(&pass.id),
timestamp_writes: None,
timestamp_writes,
});
command.set_pipeline(pipeline);
for (group, bind_group) in bind_groups {
@@ -133,11 +209,19 @@ impl Wgpu {
pass.dispatch[2],
);
}
GpuPass::Render(bundle) => {
GpuPass::Render {
first,
last,
bundle,
..
} => {
let pass = &loadout.graph.passes[*first];
let last = &loadout.graph.passes[*last];
let colors = pass
.color
.iter()
.map(|attachment| {
.zip(&last.color)
.map(|(attachment, final_attachment)| {
Ok(Some(wgpu::RenderPassColorAttachment {
view: view(
&loadout.resources,
@@ -146,39 +230,57 @@ impl Wgpu {
)?,
depth_slice: None,
resolve_target: None,
ops: color_ops(attachment)?,
ops: color_ops(attachment, final_attachment)?,
}))
})
.collect::<Result<Vec<_>, String>>()?;
let depth = pass
.depth
.as_ref()
.map(|attachment| {
.zip(last.depth.as_ref())
.map(|(attachment, final_attachment)| {
Ok::<_, String>(wgpu::RenderPassDepthStencilAttachment {
view: view(
&loadout.resources,
&surface_view,
&attachment.resource,
)?,
depth_ops: Some(depth_ops(attachment)?),
depth_ops: Some(depth_ops(attachment, final_attachment)?),
stencil_ops: None,
})
})
.transpose()?;
let timestamp_writes = profile
.then(|| loadout.resources.render_timestamps(pass_index))
.flatten();
let mut command = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(&pass.id),
color_attachments: &colors,
depth_stencil_attachment: depth,
timestamp_writes: None,
timestamp_writes,
occlusion_query_set: None,
});
command.execute_bundles([bundle]);
}
}
}
if profile {
loadout.resources.resolve_profile(&mut encoder);
}
self.queue.submit([encoder.finish()]);
let profile = profile
.then(|| {
loadout
.resources
.map_profile(self.queue.get_timestamp_period())
})
.flatten();
output.present();
Ok(())
let completed = Arc::new(AtomicBool::new(false));
let callback = completed.clone();
self.queue
.on_submitted_work_done(move || callback.store(true, Ordering::Release));
Ok(Some(Submission { completed, profile }))
}
}
@@ -196,7 +298,10 @@ fn view<'a>(
}
}
fn color_ops(attachment: &ColorAttachment) -> Result<wgpu::Operations<wgpu::Color>, String> {
fn color_ops(
attachment: &ColorAttachment,
final_attachment: &ColorAttachment,
) -> Result<wgpu::Operations<wgpu::Color>, String> {
let clear = attachment.clear.as_slice();
Ok(wgpu::Operations {
load: match attachment.load.as_str() {
@@ -209,18 +314,21 @@ fn color_ops(attachment: &ColorAttachment) -> Result<wgpu::Operations<wgpu::Colo
}),
_ => return Err("GRAPH_LOAD_OP".into()),
},
store: store_op(&attachment.store)?,
store: store_op(&final_attachment.store)?,
})
}
fn depth_ops(attachment: &DepthAttachment) -> Result<wgpu::Operations<f32>, String> {
fn depth_ops(
attachment: &DepthAttachment,
final_attachment: &DepthAttachment,
) -> Result<wgpu::Operations<f32>, String> {
Ok(wgpu::Operations {
load: match attachment.load.as_str() {
"load" => wgpu::LoadOp::Load,
"clear" => wgpu::LoadOp::Clear(attachment.clear),
_ => return Err("GRAPH_LOAD_OP".into()),
},
store: store_op(&attachment.store)?,
store: store_op(&final_attachment.store)?,
})
}
+7 -1
View File
@@ -7,6 +7,7 @@ pub struct Wgpu {
pub format: wgpu::TextureFormat,
pub width: u32,
pub height: u32,
pub timestamp_queries: bool,
}
impl Wgpu {
@@ -30,8 +31,12 @@ impl Wgpu {
})
.await
.map_err(|_| "WEBGPU_UNAVAILABLE")?;
let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY;
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor::default())
.request_device(&wgpu::DeviceDescriptor {
required_features,
..Default::default()
})
.await
.map_err(|_| "DEVICE")?;
let config = surface
@@ -48,6 +53,7 @@ impl Wgpu {
format,
width,
height,
timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY),
})
}
}
+15
View File
@@ -1,6 +1,7 @@
import initWasm, { Core } from "./pkg/yawn_core.js";
let core;
let profileTimer;
const fail = code => { throw new Error(code); };
@@ -39,6 +40,12 @@ addEventListener("message", async ({ data: message }) => {
case "switch-loadout":
core.switch_loadout(message.id);
break;
case "upload-texture":
core.upload_texture(message.name, message.image);
break;
case "delete-texture":
core.delete_texture(message.name);
break;
case "play":
core.play();
break;
@@ -48,6 +55,14 @@ addEventListener("message", async ({ data: message }) => {
case "set-fps":
core.set_fps(message.fps);
break;
case "set-profiler":
result = core.set_profiler(Boolean(message.enabled));
clearInterval(profileTimer);
profileTimer = result && message.enabled ? setInterval(() => {
const stats = core.take_profile();
if (stats) postMessage({ type: "profile", stats: JSON.parse(stats) });
}, 250) : undefined;
break;
default:
fail("MESSAGE");
}
+313 -41
View File
@@ -1,36 +1,117 @@
<script setup>
import { nextTick, onMounted, onUnmounted, ref } from "vue";
import { basicSetup } from "codemirror";
import { javascript } from "@codemirror/lang-javascript";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
import * as Handles from "@yawn/handles";
import { YawnCore } from "@yawn/core";
import { playgrounds } from "./playgrounds";
const props = defineProps({ example: { type: String, default: "triangle" } });
const preset = playgrounds[props.example] ?? playgrounds.triangle;
const props = defineProps({
example: { type: String, default: "triangle" },
fullscreen: { type: Boolean, default: false },
});
const examples = Object.entries(playgrounds);
const selected = ref(playgrounds[props.example] ? props.example : "triangle");
const preset = computed(() => playgrounds[selected.value]);
const canvas = ref();
const source = ref(preset.code);
const editor = ref();
const preview = ref();
const source = ref(preset.value.code);
const status = ref("Starting…");
const output = ref([]);
const failed = ref(false);
const running = ref(false);
const canvasKey = ref(0);
const fps = ref(0);
const profilerOpen = ref(false);
const profilerSupported = ref(null);
const profile = ref(null);
let generation = 0;
let current;
let stopProfile;
let fpsFrame = 0;
let sampledFrame = 0;
let sampledAt = 0;
let editorView;
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const api = { ...Handles, YawnCore };
function updateSource(value) {
source.value = value;
if (editorView && editorView.state.doc.toString() !== value) {
editorView.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: value },
});
}
}
function requestedSave() {
if (!props.fullscreen) return selected.value;
const save = new URL(location.href).searchParams.get("save");
return save && playgrounds[save] ? save : "triangle";
}
function openSave(event, save, push = true) {
event?.preventDefault();
selected.value = playgrounds[save] ? save : "triangle";
updateSource(preset.value.code);
if (props.fullscreen && push) {
const url = new URL(location.href);
url.searchParams.set("save", selected.value);
history.pushState(null, "", url);
}
run();
}
function restoreSave() {
openSave(undefined, requestedSave(), false);
}
async function dispose(value = current) {
if (!value) return;
if (value === current) {
stopProfile?.();
stopProfile = undefined;
current = undefined;
delete window.__yawnPlayground;
}
if (typeof value === "function") await value();
else if (typeof value.dispose === "function") await value.dispose();
}
async function attachProfiler(runId = generation) {
stopProfile?.();
stopProfile = undefined;
profile.value = null;
profilerSupported.value = null;
if (!profilerOpen.value) return;
const core = current?.scene?.core ?? current?.core;
if (!core?.onProfile || !core?.setProfiler) {
profilerSupported.value = false;
return;
}
stopProfile = core.onProfile((stats) => {
if (runId === generation) profile.value = stats;
});
profilerSupported.value = await core.setProfiler(true);
}
async function toggleProfiler() {
profilerOpen.value = !profilerOpen.value;
if (profilerOpen.value) {
await attachProfiler();
} else {
stopProfile?.();
stopProfile = undefined;
profile.value = null;
const core = current?.scene?.core ?? current?.core;
await core?.setProfiler?.(false);
}
}
async function run() {
const runId = ++generation;
running.value = true;
@@ -43,8 +124,12 @@ async function run() {
throw new Error("Cross-origin isolation is disabled");
canvasKey.value++;
await nextTick();
canvas.value.width = 960;
canvas.value.height = 540;
canvas.value.width = props.fullscreen
? Math.max(1, preview.value.clientWidth)
: 960;
canvas.value.height = props.fullscreen
? Math.max(1, preview.value.clientHeight)
: 540;
const executable = source.value.replace(/^\s*import\s+[^;]+;\s*$/gm, "");
const names = Object.keys(api);
const started = performance.now();
@@ -54,7 +139,7 @@ async function run() {
"log",
executable,
)(...names.map((name) => api[name]), canvas.value, (message) =>
output.value.push(String(message)),
runId === generation && output.value.push(String(message)),
);
if (runId !== generation) {
await dispose(result);
@@ -62,29 +147,23 @@ async function run() {
}
current = result;
window.__yawnPlayground = result;
await attachProfiler(runId);
status.value = `Running · ${Math.round(performance.now() - started)} ms`;
} catch (error) {
if (runId === generation) {
failed.value = true;
status.value = error instanceof Error ? error.message : String(error);
}
} finally {
if (runId === generation) running.value = false;
}
}
function reset() {
source.value = preset.code;
updateSource(preset.value.code);
run();
}
function tab(event) {
if (event.key !== "Tab") return;
event.preventDefault();
const editor = event.currentTarget;
const start = editor.selectionStart;
source.value = `${source.value.slice(0, start)} ${source.value.slice(editor.selectionEnd)}`;
requestAnimationFrame(() => editor.setSelectionRange(start + 2, start + 2));
}
function sampleFps(time = performance.now()) {
try {
const core = current?.scene?.core ?? current?.core;
@@ -95,9 +174,8 @@ function sampleFps(time = performance.now()) {
sampledFrame = frame;
sampledAt = time;
} else if (time - sampledAt >= 500) {
fps.value = Math.round(
((frame - sampledFrame) * 1000) / (time - sampledAt),
);
const measured = ((frame - sampledFrame) * 1000) / (time - sampledAt);
fps.value = measured < 10 ? measured.toFixed(1) : Math.round(measured);
sampledFrame = frame;
sampledAt = time;
}
@@ -110,19 +188,46 @@ function sampleFps(time = performance.now()) {
}
onMounted(() => {
selected.value = requestedSave();
source.value = preset.value.code;
editorView = new EditorView({
doc: source.value,
parent: editor.value,
extensions: [
basicSetup,
javascript(),
oneDark,
EditorView.lineWrapping,
EditorView.contentAttributes.of({
"aria-label": "Editable playground code",
spellcheck: "false",
}),
EditorView.updateListener.of((update) => {
if (update.docChanged) source.value = update.state.doc.toString();
}),
],
});
if (props.fullscreen) addEventListener("popstate", restoreSave);
sampleFps();
run();
});
onUnmounted(() => {
generation++;
removeEventListener("popstate", restoreSave);
editorView?.destroy();
cancelAnimationFrame(fpsFrame);
dispose();
});
</script>
<template>
<section class="playground" :aria-label="`${preset.title} playground`">
<section
class="playground"
:class="{ fullscreen }"
:aria-label="`${preset.title} playground`"
>
<header>
<a v-if="fullscreen" class="brand" href="/">Yawn</a>
<strong>{{ preset.title }}</strong>
<span :class="{ failed }" data-playground-status>{{ status }}</span>
<button
@@ -133,21 +238,54 @@ onUnmounted(() => {
>
Reset
</button>
<button
v-if="fullscreen"
type="button"
class="secondary"
:aria-pressed="profilerOpen"
data-profiler-toggle
@click="toggleProfiler"
>
Profile
</button>
<button type="button" :disabled="running" @click="run">
{{ running ? "Running…" : "Run" }}
</button>
</header>
<nav v-if="fullscreen" class="saves" aria-label="Saved playgrounds">
<span>Saved</span>
<a
v-for="([save, item]) in examples"
:key="save"
:href="`/playground?save=${save}`"
:aria-current="save === selected ? 'page' : undefined"
@click="openSave($event, save)"
>
{{ item.title }}
</a>
</nav>
<div class="workspace">
<textarea
v-model="source"
aria-label="Editable playground code"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
@keydown="tab"
/>
<div class="preview">
<div ref="editor" class="editor" />
<div ref="preview" class="preview">
<canvas :key="canvasKey" ref="canvas" aria-label="Yawn WebGPU output" />
<aside v-if="profilerOpen" class="profiler" data-playground-profiler>
<div class="profiler-title">
<strong>GPU passes</strong>
<span v-if="profile">{{ profile.milliseconds.toFixed(2) }} ms</span>
</div>
<p v-if="profilerSupported === false">
Timestamp queries are unavailable on this GPU.
</p>
<p v-else-if="!profile">Waiting for a completed frame</p>
<table v-else>
<tbody>
<tr v-for="(pass, index) in profile.passes" :key="`${index}-${pass.name}`">
<th>{{ pass.name }}</th>
<td>{{ pass.milliseconds.toFixed(2) }} ms</td>
</tr>
</tbody>
</table>
</aside>
<div v-if="output.length" class="output" data-playground-log>
<div v-for="(line, index) in output" :key="index">{{ line }}</div>
</div>
@@ -170,6 +308,22 @@ onUnmounted(() => {
background: #0b1020;
box-shadow: var(--vp-shadow-3);
}
.playground.fullscreen {
position: fixed;
z-index: 100;
inset: 0;
left: 0;
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
height: 100dvh;
margin: 0;
transform: none;
border: 0;
border-radius: 0;
box-shadow: none;
}
:global(.VPContent.has-sidebar .playground) {
left: calc(50% + var(--vp-sidebar-width) / 2);
width: min(1120px, calc(100vw - var(--vp-sidebar-width) - 64px));
@@ -193,6 +347,14 @@ header {
header strong {
white-space: nowrap;
}
.brand {
padding-right: 12px;
color: #60a5fa;
border-right: 1px solid #334155;
font-weight: 800;
letter-spacing: -0.02em;
text-decoration: none;
}
header span {
min-width: 0;
flex: 1;
@@ -223,24 +385,64 @@ button:disabled {
cursor: wait;
opacity: 0.55;
}
.saves {
display: flex;
min-height: 34px;
align-items: center;
gap: 5px;
overflow-x: auto;
padding: 4px 10px;
color: #64748b;
border-bottom: 1px solid #263249;
background: #0d1424;
font:
11px/1 ui-monospace,
SFMono-Regular,
Menlo,
monospace;
white-space: nowrap;
}
.saves span {
margin-right: 3px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.saves a {
padding: 5px 7px;
color: #94a3b8;
border-radius: 5px;
text-decoration: none;
}
.saves a:hover {
color: #e2e8f0;
background: #1e293b;
}
.saves a[aria-current="page"] {
color: #bfdbfe;
background: #1d4ed8;
}
.workspace {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 510px;
}
textarea {
box-sizing: border-box;
width: 100%;
.fullscreen .workspace {
min-height: 0;
flex: 1;
}
.editor {
min-width: 0;
height: 510px;
resize: none;
padding: 18px;
color: #dbeafe;
border: 0;
border-right: 1px solid #263249;
outline: none;
background: #0b1020;
tab-size: 2;
}
.fullscreen .editor {
height: auto;
min-height: 0;
}
.editor :deep(.cm-editor) {
height: 100%;
background: #0b1020;
font:
13px/1.55 ui-monospace,
SFMono-Regular,
@@ -248,7 +450,17 @@ textarea {
Consolas,
monospace;
}
textarea:focus {
.editor :deep(.cm-scroller) {
overflow: auto;
font-family: inherit;
}
.editor :deep(.cm-gutters) {
color: #526079;
border-right-color: #263249;
background: #0d1424;
}
.editor :deep(.cm-focused) {
outline: none;
box-shadow: inset 0 0 0 2px #2563eb;
}
.preview {
@@ -281,6 +493,59 @@ canvas {
Menlo,
monospace;
}
.profiler {
position: absolute;
z-index: 2;
top: 0;
right: 0;
bottom: 0;
width: min(320px, 72%);
overflow: auto;
padding: 16px;
color: #cbd5e1;
border-left: 1px solid #334155;
background: rgb(2 6 23 / 94%);
font:
12px/1.45 ui-monospace,
SFMono-Regular,
Menlo,
monospace;
}
.profiler-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
color: #e2e8f0;
}
.profiler-title span {
color: #60a5fa;
font-weight: 700;
}
.profiler p {
color: #94a3b8;
}
.profiler table {
width: 100%;
border-collapse: collapse;
}
.profiler th,
.profiler td {
padding: 7px 0;
border-bottom: 1px solid #1e293b;
}
.profiler th {
overflow: hidden;
max-width: 190px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.profiler td {
color: #93c5fd;
text-align: right;
white-space: nowrap;
}
.fps {
position: absolute;
right: 12px;
@@ -309,14 +574,21 @@ canvas {
.workspace {
grid-template-columns: 1fr;
}
textarea {
.editor {
height: 360px;
border-right: 0;
border-bottom: 1px solid #263249;
}
.fullscreen .editor {
height: 45vh;
min-height: 240px;
}
.preview {
min-height: 360px;
}
.fullscreen .preview {
min-height: 0;
}
header strong {
display: none;
}
+25 -6
View File
@@ -137,12 +137,25 @@ log(\`Two mesh handles share geometry #\${source.geometryId}.\`);
return { scene, source, instance, dispose: () => scene.dispose() };`;
const materials = `import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
const materials = `import { Mesh, PBRMaterial, Scene, Texture } from "@yawn/handles";
const scene = new Scene(canvas, { hdr: true });
await scene.ready;
const pixels = new OffscreenCanvas(64, 64);
const context = pixels.getContext("2d");
context.fillStyle = "#ff3018";
context.fillRect(0, 0, 64, 64);
context.fillStyle = "#ffd166";
context.fillRect(0, 0, 32, 32);
context.fillRect(32, 32, 32, 32);
const albedo = new Texture(scene, {
source: pixels.transferToImageBitmap(),
format: "rgba8unorm-srgb",
});
await albedo.ready;
const paint = new PBRMaterial(scene, {
baseColor: [0.85, 0.08, 0.18, 1],
baseColor: [1, 1, 1, 1],
baseColorTexture: albedo,
metallic: 0.75,
roughness: 0.18,
});
@@ -151,6 +164,8 @@ const mesh = new Mesh(scene, {
material: paint,
vertexData: {
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1],
uvs: [0, 0, 2, 0, 1, 2],
indices: [0, 1, 2],
},
});
@@ -167,6 +182,7 @@ return {
scene,
mesh,
paint,
albedo,
dispose() {
canvas.removeEventListener("pointermove", move);
scene.dispose();
@@ -256,7 +272,7 @@ return { scene, mesh, exposure, grade, fxaa, dispose: () => scene.dispose() };`;
const importing = `import { AmbientLight, ArcRotateCamera, Picking, Scene, importGltf } from "@yawn/handles";
const scene = new Scene(canvas, { hdr: true, fps: 1, arenaBytes: 384 * 1024 * 1024 });
const scene = new Scene(canvas, { hdr: true, arenaBytes: 384 * 1024 * 1024 });
await scene.ready;
await scene.core.pause();
log("Importing /models/sponza.glb in the importer worker…");
@@ -307,9 +323,12 @@ for (const mesh of meshes) {
}
const camera = new ArcRotateCamera(scene, {
alpha: 0.7,
beta: 1.1,
radius: 2.7,
targetPosition: [-0.3, 0, 0],
alpha: Math.PI / 2,
beta: Math.PI / 2,
radius: 0.3,
near: 0.01,
far: 10,
aspect: canvas.width / canvas.height,
controls: { element: canvas, pointer: true },
});
+16 -1
View File
@@ -39,10 +39,25 @@ const id = await core.allocateObject("application.values");
values.row(id).set([1, 2, 3, 4]);
```
Graph frontends serialize plain data to `(yawn-graph 1 ...)`. Named `after` edges preserve DAG fan-out; Rust sorts passes, detects cycles, culls unused declarations, plans compatible transient lifetimes, and allocates the active loadout.
Graph frontends serialize plain data to `(yawn-graph 1 ...)`. Named `after` edges preserve DAG fan-out; Rust sorts passes, detects cycles, culls unused declarations, aliases compatible transient lifetimes, merges compatible render passes into bundles, and allocates the active loadout. Unchanged persistent textures survive loadout rebuilds.
The `Scene` addon is one such frontend. It is replaceable and has no privileged core API.
## Profile physical GPU passes
```ts
const stop = core.onProfile((frame) => {
console.table(frame.passes); // name + GPU milliseconds
});
const supported = await core.setProfiler(true);
// Later:
await core.setProfiler(false);
stop();
```
`new YawnCore(canvas, { debug: true })` enables the same timestamp-query mode at startup. Timings describe the physical compute and render passes produced by graph compilation, so 138 compatible draws appear as one bundled forward pass. The fullscreen playgrounds **Profile** button shows the stream in a toggleable sidebar.
<Playground example="core" />
<script setup>
+2 -4
View File
@@ -22,17 +22,15 @@ mesh.material = paint;
```ts
const albedo = new Texture(scene, {
source: "/textures/paint.ktx2",
size: [2048, 2048, 1],
source: "/textures/paint.png",
format: "rgba8unorm-srgb",
usage: ["sampled", "copyDst"],
});
await albedo.ready;
const textured = new PBRMaterial(scene, { baseColorTexture: albedo });
```
Creating or removing a `Texture` rebuilds the single graph loadout so the GPU resource is allocated up front. The source pointer stays on the handle for an importer or application uploader; core never owns image-loading policy.
Creating or removing a `Texture` rebuilds the single graph loadout so the GPU resource is allocated up front. The addon decodes URLs outside core, then transfers an `ImageBitmap` to the render worker; compatible loadout rebuilds reuse the allocated GPU texture instead of uploading it again.
## Custom WGSL
+4 -4
View File
@@ -1,8 +1,8 @@
# Minimal playground
---
layout: false
---
This page creates an HDR `Scene`, one PBR material, and one indexed mesh. Move the pointer over the canvas: the handler writes the `sceneAccent` shared row directly without messaging the renderer.
<Playground />
<Playground fullscreen />
<script setup>
import Playground from "./.vitepress/Playground.vue";
+203
View File
@@ -12,6 +12,9 @@
"addons/*"
],
"devDependencies": {
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/theme-one-dark": "^6.1.3",
"codemirror": "^6.0.2",
"vitepress": "^1.6.4"
}
},
@@ -334,6 +337,123 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz",
"integrity": "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.9",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.9.tgz",
"integrity": "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@docsearch/css": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz",
@@ -783,6 +903,52 @@
"dev": true,
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz",
@@ -1295,6 +1461,22 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -1322,6 +1504,13 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1870,6 +2059,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
"license": "MIT"
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
@@ -2207,6 +2403,13 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT"
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+3
View File
@@ -8,6 +8,9 @@
"start": "wasm-pack build core --target web --out-dir pkg --release && vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}"
},
"devDependencies": {
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/theme-one-dark": "^6.1.3",
"codemirror": "^6.0.2",
"vitepress": "^1.6.4"
}
}