Optimize forward rendering and add benchmark
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:
@@ -12,6 +12,7 @@ export type GraphTexture = {
|
||||
format?: string;
|
||||
size?: [number | "canvas", number | "canvas", number?];
|
||||
usage?: string[];
|
||||
mipLevelCount?: number;
|
||||
transient?: boolean;
|
||||
};
|
||||
|
||||
@@ -19,6 +20,7 @@ export type GraphSampler = {
|
||||
id: string;
|
||||
magFilter?: "nearest" | "linear";
|
||||
minFilter?: "nearest" | "linear";
|
||||
mipmapFilter?: "nearest" | "linear";
|
||||
addressModeU?: "clamp-to-edge" | "repeat" | "mirror-repeat";
|
||||
addressModeV?: "clamp-to-edge" | "repeat" | "mirror-repeat";
|
||||
};
|
||||
|
||||
+28
-34
@@ -38,6 +38,7 @@ const rows = [
|
||||
["meshInfo", 16, "u32"],
|
||||
["bounds", 32, "f32"],
|
||||
["cameras", 80, "f32"],
|
||||
["cameraMatrices", 80, "f32"],
|
||||
["materials", 48, "f32"],
|
||||
["materialTextures", 32, "u32"],
|
||||
["pointLights", 32, "f32"],
|
||||
@@ -94,7 +95,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
const basicForwardShader = /* wgsl */ `
|
||||
struct Accent { color: vec4<f32> }
|
||||
struct VertexOutput {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@invariant @builtin(position) position: vec4<f32>,
|
||||
@location(0) normal: vec3<f32>,
|
||||
@location(1) @interpolate(flat) mesh: u32,
|
||||
@location(2) @interpolate(flat) material: u32,
|
||||
@@ -107,7 +108,7 @@ struct VertexOutput {
|
||||
@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>>;
|
||||
@group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<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);
|
||||
@@ -119,18 +120,14 @@ fn vertex(@location(0) point: vec3<f32>, @builtin(instance_index) packed: u32) -
|
||||
let visible = meshInfo[instance * 4u + 2u];
|
||||
let transformed = rotate(quaternions[instance], point * scales[instance].xyz) + positions[instance].xyz;
|
||||
var clip = vec4<f32>(transformed, 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, transformed - 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);
|
||||
}
|
||||
if (cameraMatrices[4].w != 0.0) {
|
||||
let world = vec4(transformed, 1.0);
|
||||
clip = vec4(
|
||||
dot(cameraMatrices[0], world),
|
||||
dot(cameraMatrices[1], world),
|
||||
dot(cameraMatrices[2], world),
|
||||
dot(cameraMatrices[3], world),
|
||||
);
|
||||
}
|
||||
var output: VertexOutput;
|
||||
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), clip, visible != 0u);
|
||||
@@ -159,7 +156,7 @@ function pbrShader(mask: number) {
|
||||
const normalTexture = mask & 4;
|
||||
return /* wgsl */ `
|
||||
struct VertexOutput {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@invariant @builtin(position) position: vec4<f32>,
|
||||
@location(0) world: vec3<f32>,
|
||||
@location(1) normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
@@ -175,7 +172,7 @@ struct VertexOutput {
|
||||
@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>>;
|
||||
@group(0) @binding(7) var<storage, read> cameraMatrices: 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>;" : ""}
|
||||
@@ -197,18 +194,14 @@ fn vertex(
|
||||
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);
|
||||
}
|
||||
if (cameraMatrices[4].w != 0.0) {
|
||||
let homogeneous = vec4(world, 1.0);
|
||||
clip = vec4(
|
||||
dot(cameraMatrices[0], homogeneous),
|
||||
dot(cameraMatrices[1], homogeneous),
|
||||
dot(cameraMatrices[2], homogeneous),
|
||||
dot(cameraMatrices[3], homogeneous),
|
||||
);
|
||||
}
|
||||
var output: VertexOutput;
|
||||
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), clip, meshInfo[instance * 4u + 2u] != 0u);
|
||||
@@ -234,8 +227,7 @@ fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
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 view = normalize(cameraMatrices[4].xyz - input.world);
|
||||
let lightDirection = normalize(vec3<f32>(0.4, 0.7, 0.6));
|
||||
let halfVector = normalize(lightDirection + view);
|
||||
let nDotL = max(dot(normal, lightDirection), 0.0);
|
||||
@@ -683,7 +675,6 @@ export class Scene {
|
||||
])
|
||||
addBuffer({ id, array, usage: ["storage"] });
|
||||
addBuffer({ id: "accent", array: "sceneAccent", usage: ["uniform"] });
|
||||
|
||||
computePipelines.push({
|
||||
id: "cluster-lights",
|
||||
code: clusterShader,
|
||||
@@ -747,6 +738,7 @@ export class Scene {
|
||||
id: "material-linear",
|
||||
magFilter: "linear",
|
||||
minFilter: "linear",
|
||||
mipmapFilter: "linear",
|
||||
addressModeU: "repeat",
|
||||
addressModeV: "repeat",
|
||||
});
|
||||
@@ -778,7 +770,7 @@ export class Scene {
|
||||
["node-scales", "nodeScales"],
|
||||
["mesh-info", "meshInfo"],
|
||||
["materials", "materials"],
|
||||
["cameras", "cameras"],
|
||||
["camera-matrices", "cameraMatrices"],
|
||||
])
|
||||
addBuffer({ id, array, usage: ["storage"] });
|
||||
const forwardPipelines = new Set<string>();
|
||||
@@ -841,7 +833,9 @@ export class Scene {
|
||||
const material =
|
||||
draw.material ?? Number(this.array("meshInfo").row(mesh.id)[1]);
|
||||
if (mesh.id > 65535 || material > 65534)
|
||||
throw new RangeError("Scene handle limit");
|
||||
throw new RangeError(
|
||||
`Scene handle limit: mesh ${mesh.id}, material ${material}`,
|
||||
);
|
||||
const pointers = this.array("materialTextures").row(material);
|
||||
const texture = (lane: number) =>
|
||||
pointers[lane]
|
||||
@@ -955,7 +949,7 @@ export class Scene {
|
||||
"node-scales",
|
||||
"mesh-info",
|
||||
"materials",
|
||||
"cameras",
|
||||
"camera-matrices",
|
||||
].map((resource, binding) => ({
|
||||
group: 0,
|
||||
binding,
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
function rotate(q: ArrayLike<number>, value: number[]) {
|
||||
const [x, y, z] = value;
|
||||
const tx = 2 * (q[1] * z - q[2] * y);
|
||||
const ty = 2 * (q[2] * x - q[0] * z);
|
||||
const tz = 2 * (q[0] * y - q[1] * x);
|
||||
return [
|
||||
x + q[3] * tx + q[1] * tz - q[2] * ty,
|
||||
y + q[3] * ty + q[2] * tx - q[0] * tz,
|
||||
z + q[3] * tz + q[0] * ty - q[1] * tx,
|
||||
];
|
||||
}
|
||||
|
||||
function dot(left: number[], right: ArrayLike<number>) {
|
||||
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
|
||||
}
|
||||
|
||||
export type CameraOptions = NodeOptions & {
|
||||
fov?: number;
|
||||
near?: number;
|
||||
@@ -24,6 +40,7 @@ export class Camera extends Node {
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
this.cameraId = await scene.core.allocateObject("cameras");
|
||||
await scene.ensureRows("cameraMatrices", this.cameraId + 1, 80, "f32");
|
||||
const row = scene.array("cameras").row(this.cameraId);
|
||||
row.set([
|
||||
options.fov ?? Math.PI / 3,
|
||||
@@ -39,6 +56,7 @@ export class Camera extends Node {
|
||||
options.sensorWidth ?? 36,
|
||||
1,
|
||||
]);
|
||||
this.refreshMatrix();
|
||||
return this;
|
||||
});
|
||||
}
|
||||
@@ -49,17 +67,20 @@ export class Camera extends Node {
|
||||
}
|
||||
|
||||
get fov() { return this.cameraRow()[0]; }
|
||||
set fov(value: number) { this.cameraRow()[0] = value; }
|
||||
set fov(value: number) { this.cameraRow()[0] = value; this.refreshMatrix(); }
|
||||
get aspect() { return this.cameraRow()[1]; }
|
||||
set aspect(value: number) { this.cameraRow()[1] = value; }
|
||||
set aspect(value: number) { this.cameraRow()[1] = value; this.refreshMatrix(); }
|
||||
get near() { return this.cameraRow()[2]; }
|
||||
set near(value: number) { this.cameraRow()[2] = value; }
|
||||
set near(value: number) { this.cameraRow()[2] = value; this.refreshMatrix(); }
|
||||
get far() { return this.cameraRow()[3]; }
|
||||
set far(value: number) { this.cameraRow()[3] = value; }
|
||||
set far(value: number) { this.cameraRow()[3] = value; this.refreshMatrix(); }
|
||||
get projection() { return this.cameraRow()[5] === 1 ? "orthographic" : "perspective"; }
|
||||
set projection(value: "perspective" | "orthographic") { this.cameraRow()[5] = value === "orthographic" ? 1 : 0; }
|
||||
set projection(value: "perspective" | "orthographic") {
|
||||
this.cameraRow()[5] = value === "orthographic" ? 1 : 0;
|
||||
this.refreshMatrix();
|
||||
}
|
||||
get orthoSize() { return this.cameraRow()[6]; }
|
||||
set orthoSize(value: number) { this.cameraRow()[6] = value; }
|
||||
set orthoSize(value: number) { this.cameraRow()[6] = value; this.refreshMatrix(); }
|
||||
get focalLength() { return this.cameraRow()[7]; }
|
||||
set focalLength(value: number) { this.cameraRow()[7] = value; }
|
||||
get aperture() { return this.cameraRow()[8]; }
|
||||
@@ -69,6 +90,18 @@ export class Camera extends Node {
|
||||
get sensorWidth() { return this.cameraRow()[10]; }
|
||||
set sensorWidth(value: number) { this.cameraRow()[10] = value; }
|
||||
|
||||
override get position(): Float32Array { return super.position; }
|
||||
override set position(value: ArrayLike<number>) {
|
||||
super.position = value;
|
||||
this.refreshMatrix();
|
||||
}
|
||||
|
||||
override get quaternion(): Float32Array { return super.quaternion; }
|
||||
override set quaternion(value: ArrayLike<number>) {
|
||||
super.quaternion = value;
|
||||
this.refreshMatrix();
|
||||
}
|
||||
|
||||
lookAt(target: ArrayLike<number>) {
|
||||
if (target.length !== 3) throw new RangeError("camera target");
|
||||
const position = this.position;
|
||||
@@ -86,6 +119,47 @@ export class Camera extends Node {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected refreshMatrix() {
|
||||
if (this.id < 0 || this.cameraId < 0) return;
|
||||
const camera = this.cameraRow();
|
||||
const position = this.position;
|
||||
const quaternion = this.quaternion;
|
||||
const right = rotate(quaternion, [1, 0, 0]);
|
||||
const up = rotate(quaternion, [0, 1, 0]);
|
||||
const forward = rotate(quaternion, [0, 0, 1]);
|
||||
let rows: number[][];
|
||||
if (camera[5] === 1) {
|
||||
const size = Math.max(camera[6], 0.0001);
|
||||
const x = 2 / (size * camera[1]);
|
||||
const y = 2 / size;
|
||||
const z = -1 / camera[3];
|
||||
rows = [
|
||||
[...right.map((value) => value * x), -dot(right, position) * x],
|
||||
[...up.map((value) => value * y), -dot(up, position) * y],
|
||||
[...forward.map((value) => value * z), -dot(forward, position) * z],
|
||||
[0, 0, 0, 1],
|
||||
];
|
||||
} else {
|
||||
const focal = 1 / Math.tan(camera[0] * 0.5);
|
||||
const x = focal / camera[1];
|
||||
const z = -camera[3] / (camera[3] - camera[2]);
|
||||
const translation = (-camera[2] * camera[3]) / (camera[3] - camera[2]);
|
||||
rows = [
|
||||
[...right.map((value) => value * x), -dot(right, position) * x],
|
||||
[...up.map((value) => value * focal), -dot(up, position) * focal],
|
||||
[
|
||||
...forward.map((value) => value * z),
|
||||
-dot(forward, position) * z + translation,
|
||||
],
|
||||
[...forward.map((value) => -value), dot(forward, position)],
|
||||
];
|
||||
}
|
||||
this.scene
|
||||
.array("cameraMatrices")
|
||||
.row(this.cameraId)
|
||||
.set([...rows.flat(), ...position, camera[11]]);
|
||||
}
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.#cameraDisposed) return;
|
||||
|
||||
@@ -91,6 +91,7 @@ export class FreeCamera extends Camera {
|
||||
this.position[0] += (x * Math.cos(yaw) + z * Math.sin(yaw)) * speed;
|
||||
this.position[1] += y * speed;
|
||||
this.position[2] += (x * -Math.sin(yaw) + z * Math.cos(yaw)) * speed;
|
||||
this.refreshMatrix();
|
||||
this.#frame = requestAnimationFrame(this.#update);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export type TextureOptions = {
|
||||
size?: [number | "canvas", number | "canvas", number?];
|
||||
format?: string;
|
||||
usage?: string[];
|
||||
mipmaps?: boolean;
|
||||
transient?: boolean;
|
||||
};
|
||||
|
||||
@@ -29,6 +30,10 @@ export class Texture {
|
||||
typeof options.source === "string"
|
||||
? await createImageBitmap(await (await fetch(options.source)).blob())
|
||||
: options.source;
|
||||
const mipLevelCount =
|
||||
image instanceof ImageBitmap && options.mipmaps !== false
|
||||
? Math.floor(Math.log2(Math.max(image.width, image.height))) + 1
|
||||
: 1;
|
||||
const registration = scene.registerTexture({
|
||||
id: resource,
|
||||
source: image,
|
||||
@@ -38,6 +43,7 @@ export class Texture {
|
||||
? [image.width, image.height, 1]
|
||||
: [1, 1, 1]),
|
||||
format: options.format ?? "rgba8unorm",
|
||||
mipLevelCount,
|
||||
usage: [
|
||||
...new Set([
|
||||
...(options.usage ?? ["copyDst"]),
|
||||
@@ -49,8 +55,22 @@ export class Texture {
|
||||
});
|
||||
this.id = registration.number;
|
||||
await registration.ready;
|
||||
if (image instanceof ImageBitmap)
|
||||
await scene.core.uploadTexture(resource, image);
|
||||
if (image instanceof ImageBitmap) {
|
||||
const levels = [image];
|
||||
for (let level = 1; level < mipLevelCount; level++)
|
||||
levels.push(
|
||||
await createImageBitmap(image, {
|
||||
resizeWidth: Math.max(1, image.width >> level),
|
||||
resizeHeight: Math.max(1, image.height >> level),
|
||||
resizeQuality: "high",
|
||||
}),
|
||||
);
|
||||
await Promise.all(
|
||||
levels.map((level, index) =>
|
||||
scene.core.uploadTexture(resource, level, index),
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -119,11 +119,20 @@ export class YawnCore {
|
||||
return this.#request("switch-loadout", { id });
|
||||
}
|
||||
|
||||
async uploadTexture(name, image) {
|
||||
async uploadTexture(name, image, mipLevel = 0) {
|
||||
await this.ready;
|
||||
if (typeof name !== "string" || !(image instanceof ImageBitmap))
|
||||
if (
|
||||
typeof name !== "string" ||
|
||||
!(image instanceof ImageBitmap) ||
|
||||
!Number.isInteger(mipLevel) ||
|
||||
mipLevel < 0
|
||||
)
|
||||
throw new TypeError("TEXTURE_SOURCE");
|
||||
return this.#request("upload-texture", { name, image }, [image]);
|
||||
return this.#request(
|
||||
"upload-texture",
|
||||
{ name, image, mipLevel },
|
||||
[image],
|
||||
);
|
||||
}
|
||||
|
||||
async deleteTexture(name) {
|
||||
|
||||
+7
-2
@@ -174,14 +174,19 @@ impl Core {
|
||||
.map_err(|error| JsError::new(&error))
|
||||
}
|
||||
|
||||
pub fn upload_texture(&self, name: String, image: web_sys::ImageBitmap) -> Result<(), JsError> {
|
||||
pub fn upload_texture(
|
||||
&self,
|
||||
name: String,
|
||||
mip_level: u32,
|
||||
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)
|
||||
.upload_texture(name, mip_level, image, gpu)
|
||||
.map_err(|error| JsError::new(&error))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::num::NonZeroU64;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -46,7 +46,7 @@ pub struct GpuTexture {
|
||||
pub texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
key: String,
|
||||
uploaded: bool,
|
||||
uploaded_mips: HashSet<u32>,
|
||||
}
|
||||
|
||||
pub enum GpuPass {
|
||||
@@ -124,7 +124,7 @@ impl GpuResources {
|
||||
view: texture.create_view(&wgpu::TextureViewDescriptor::default()),
|
||||
texture,
|
||||
key,
|
||||
uploaded: false,
|
||||
uploaded_mips: HashSet::new(),
|
||||
});
|
||||
physical_slots.insert(source.slot, physical);
|
||||
physical
|
||||
@@ -211,21 +211,29 @@ impl GpuResources {
|
||||
};
|
||||
for execution in &graph.executions {
|
||||
let compiled = match execution {
|
||||
Execution::Render(passes) => GpuPass::Render {
|
||||
Execution::Render(passes) => {
|
||||
let (bundle, draws) = resources.render_bundle(graph, passes, gpu)?;
|
||||
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())
|
||||
format!("Forward ({draws} draws)")
|
||||
} else if passes
|
||||
.iter()
|
||||
.all(|index| graph.passes[*index].id.starts_with("depth-"))
|
||||
{
|
||||
format!("Depth ({draws} draws)")
|
||||
} else {
|
||||
format!("Render ({} draws)", passes.len())
|
||||
format!("Render ({draws} draws)")
|
||||
},
|
||||
first: passes[0],
|
||||
last: *passes.last().unwrap(),
|
||||
bundle: resources.render_bundle(graph, passes, gpu)?,
|
||||
},
|
||||
bundle,
|
||||
}
|
||||
}
|
||||
Execution::Compute(index) => {
|
||||
let pass = &graph.passes[*index];
|
||||
let pipeline = resources
|
||||
@@ -261,6 +269,7 @@ impl GpuResources {
|
||||
pub fn upload_texture(
|
||||
&mut self,
|
||||
id: &str,
|
||||
mip_level: u32,
|
||||
image: &web_sys::ImageBitmap,
|
||||
gpu: &Wgpu,
|
||||
) -> Result<(), String> {
|
||||
@@ -277,7 +286,7 @@ impl GpuResources {
|
||||
},
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &texture.texture,
|
||||
mip_level: 0,
|
||||
mip_level,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
}
|
||||
@@ -288,15 +297,15 @@ impl GpuResources {
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
texture.uploaded = true;
|
||||
texture.uploaded_mips.insert(mip_level);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn needs_upload(&self, id: &str) -> bool {
|
||||
pub fn needs_upload(&self, id: &str, mip_level: u32) -> bool {
|
||||
self.texture_slots
|
||||
.get(id)
|
||||
.and_then(|slot| self.textures.get(*slot))
|
||||
.is_some_and(|texture| !texture.uploaded)
|
||||
.is_some_and(|texture| !texture.uploaded_mips.contains(&mip_level))
|
||||
}
|
||||
|
||||
pub fn render_timestamps(&self, pass: usize) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
|
||||
@@ -396,7 +405,7 @@ impl GpuResources {
|
||||
graph: &RenderGraph,
|
||||
passes: &[usize],
|
||||
gpu: &Wgpu,
|
||||
) -> Result<wgpu::RenderBundle, String> {
|
||||
) -> Result<(wgpu::RenderBundle, usize), String> {
|
||||
let pass = &graph.passes[passes[0]];
|
||||
let declaration = graph
|
||||
.pipelines
|
||||
@@ -431,8 +440,10 @@ impl GpuResources {
|
||||
});
|
||||
let mut previous_pipeline = None;
|
||||
let mut previous_bindings: Option<&[Binding]> = None;
|
||||
for index in passes {
|
||||
let pass = &graph.passes[*index];
|
||||
let mut draws = 0;
|
||||
let mut at = 0;
|
||||
while at < passes.len() {
|
||||
let pass = &graph.passes[passes[at]];
|
||||
let pipeline = self
|
||||
.render_pipelines
|
||||
.get(&pass.pipeline)
|
||||
@@ -468,10 +479,17 @@ impl GpuResources {
|
||||
buffer.slice(binding.offset..),
|
||||
parse(&binding.format, "GRAPH_INDEX_FORMAT")?,
|
||||
);
|
||||
let mut instances = pass.draw.instances;
|
||||
while at + 1 < passes.len()
|
||||
&& can_instance(pass, &graph.passes[passes[at + 1]], instances)
|
||||
{
|
||||
at += 1;
|
||||
instances += graph.passes[passes[at]].draw.instances;
|
||||
}
|
||||
encoder.draw_indexed(
|
||||
pass.draw.first_index..pass.draw.first_index + pass.draw.indices,
|
||||
pass.draw.base_vertex,
|
||||
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
|
||||
pass.draw.first_instance..pass.draw.first_instance + instances,
|
||||
);
|
||||
} else {
|
||||
encoder.draw(
|
||||
@@ -479,10 +497,15 @@ impl GpuResources {
|
||||
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
|
||||
);
|
||||
}
|
||||
draws += 1;
|
||||
at += 1;
|
||||
}
|
||||
Ok(encoder.finish(&wgpu::RenderBundleDescriptor {
|
||||
Ok((
|
||||
encoder.finish(&wgpu::RenderBundleDescriptor {
|
||||
label: Some(&pass.id),
|
||||
}))
|
||||
}),
|
||||
draws,
|
||||
))
|
||||
}
|
||||
|
||||
fn bind_groups(
|
||||
@@ -531,6 +554,18 @@ impl GpuResources {
|
||||
}
|
||||
}
|
||||
|
||||
fn can_instance(first: &Pass, next: &Pass, instances: u32) -> bool {
|
||||
first.pipeline == next.pipeline
|
||||
&& first.bindings == next.bindings
|
||||
&& first.vertex_buffers == next.vertex_buffers
|
||||
&& first.index_buffer == next.index_buffer
|
||||
&& first.draw.indices != 0
|
||||
&& first.draw.indices == next.draw.indices
|
||||
&& first.draw.first_index == next.draw.first_index
|
||||
&& first.draw.base_vertex == next.draw.base_vertex
|
||||
&& first.draw.first_instance + instances == next.draw.first_instance
|
||||
}
|
||||
|
||||
impl GpuPass {
|
||||
fn label(&self) -> &str {
|
||||
match self {
|
||||
@@ -621,7 +656,7 @@ fn create_render_pipeline(
|
||||
primitive: primitive(&source.primitive)?,
|
||||
depth_stencil: depth_stencil(&source.depth_stencil)?,
|
||||
multisample: multisample(&source.multisample)?,
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
fragment: (!targets.is_empty()).then_some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(&source.fragment.entry),
|
||||
compilation_options: Default::default(),
|
||||
|
||||
@@ -229,7 +229,7 @@ pub struct DepthAttachment {
|
||||
pub store: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct VertexBinding {
|
||||
#[serde(default)]
|
||||
pub slot: u32,
|
||||
@@ -238,7 +238,7 @@ pub struct VertexBinding {
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct IndexBinding {
|
||||
pub resource: String,
|
||||
#[serde(default = "index_format")]
|
||||
@@ -247,7 +247,7 @@ pub struct IndexBinding {
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Draw {
|
||||
#[serde(default = "three")]
|
||||
|
||||
+20
-10
@@ -13,7 +13,7 @@ pub struct Loadout {
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
graphs: HashMap<String, RenderGraph>,
|
||||
texture_sources: HashMap<String, web_sys::ImageBitmap>,
|
||||
texture_sources: HashMap<String, HashMap<u32, web_sys::ImageBitmap>>,
|
||||
active: Option<Loadout>,
|
||||
}
|
||||
|
||||
@@ -32,9 +32,11 @@ impl Store {
|
||||
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)?;
|
||||
for (name, levels) in &self.texture_sources {
|
||||
for (mip_level, image) in levels {
|
||||
if resources.needs_upload(name, *mip_level) {
|
||||
resources.upload_texture(name, *mip_level, image, gpu)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.active = Some(Loadout { graph, resources });
|
||||
@@ -44,25 +46,31 @@ impl Store {
|
||||
pub fn upload_texture(
|
||||
&mut self,
|
||||
name: String,
|
||||
mip_level: u32,
|
||||
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)?;
|
||||
active
|
||||
.resources
|
||||
.upload_texture(&name, mip_level, &image, gpu)?;
|
||||
}
|
||||
}
|
||||
if let Some(previous) = self.texture_sources.insert(name, image) {
|
||||
let levels = self.texture_sources.entry(name).or_default();
|
||||
if let Some(previous) = levels.insert(mip_level, image) {
|
||||
previous.close();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_texture(&mut self, name: &str) {
|
||||
if let Some(image) = self.texture_sources.remove(name) {
|
||||
if let Some(levels) = self.texture_sources.remove(name) {
|
||||
for image in levels.into_values() {
|
||||
image.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_mut(&mut self) -> Option<&mut Loadout> {
|
||||
self.active.as_mut()
|
||||
@@ -102,9 +110,11 @@ impl Store {
|
||||
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)?;
|
||||
for (name, levels) in &self.texture_sources {
|
||||
for (mip_level, image) in levels {
|
||||
if resources.needs_upload(name, *mip_level) {
|
||||
resources.upload_texture(name, *mip_level, image, gpu)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.active = Some(Loadout { graph, resources });
|
||||
|
||||
@@ -110,6 +110,7 @@ impl RenderLoop {
|
||||
while !submission.completed.load(Ordering::Acquire) {
|
||||
TimeoutFuture::new(0).await;
|
||||
}
|
||||
let wall_milliseconds = js_sys::Date::now() - started;
|
||||
if let Some(profile) = submission.profile {
|
||||
while profile.state() == 0 {
|
||||
TimeoutFuture::new(0).await;
|
||||
@@ -129,10 +130,15 @@ impl RenderLoop {
|
||||
.iter()
|
||||
.filter_map(|pass| pass["milliseconds"].as_f64())
|
||||
.sum::<f64>();
|
||||
let gpu = gpu.borrow();
|
||||
let gpu = gpu.as_ref().unwrap();
|
||||
*control.profile.borrow_mut() = Some(
|
||||
serde_json::json!({
|
||||
"frame": frame,
|
||||
"milliseconds": milliseconds,
|
||||
"wallMilliseconds": wall_milliseconds,
|
||||
"adapter": gpu.adapter,
|
||||
"canvas": { "width": gpu.width, "height": gpu.height },
|
||||
"passes": passes,
|
||||
})
|
||||
.to_string(),
|
||||
|
||||
+13
-1
@@ -8,6 +8,7 @@ pub struct Wgpu {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub timestamp_queries: bool,
|
||||
pub adapter: String,
|
||||
}
|
||||
|
||||
impl Wgpu {
|
||||
@@ -27,11 +28,21 @@ impl Wgpu {
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
compatible_surface: Some(&surface),
|
||||
..Default::default()
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
force_fallback_adapter: false,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "WEBGPU_UNAVAILABLE")?;
|
||||
let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY;
|
||||
let info = adapter.get_info();
|
||||
let adapter_name = if info.name.is_empty() {
|
||||
format!("{:?} · {:?}", info.device_type, info.backend)
|
||||
} else {
|
||||
format!(
|
||||
"{} · {:?} · {:?}",
|
||||
info.name, info.device_type, info.backend
|
||||
)
|
||||
};
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
required_features,
|
||||
@@ -54,6 +65,7 @@ impl Wgpu {
|
||||
width,
|
||||
height,
|
||||
timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY),
|
||||
adapter: adapter_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ addEventListener("message", async ({ data: message }) => {
|
||||
core.switch_loadout(message.id);
|
||||
break;
|
||||
case "upload-texture":
|
||||
core.upload_texture(message.name, message.image);
|
||||
core.upload_texture(message.name, message.mipLevel, message.image);
|
||||
break;
|
||||
case "delete-texture":
|
||||
core.delete_texture(message.name);
|
||||
|
||||
@@ -28,6 +28,7 @@ const fps = ref(0);
|
||||
const profilerOpen = ref(false);
|
||||
const profilerSupported = ref(null);
|
||||
const profile = ref(null);
|
||||
const adapterInfo = ref("");
|
||||
let generation = 0;
|
||||
let current;
|
||||
let stopProfile;
|
||||
@@ -88,6 +89,20 @@ async function attachProfiler(runId = generation) {
|
||||
profile.value = null;
|
||||
profilerSupported.value = null;
|
||||
if (!profilerOpen.value) return;
|
||||
if (!adapterInfo.value) {
|
||||
const adapter = await navigator.gpu?.requestAdapter({
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
const info = adapter?.info;
|
||||
adapterInfo.value = [
|
||||
info?.vendor,
|
||||
info?.architecture,
|
||||
info?.device,
|
||||
info?.description,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
const core = current?.scene?.core ?? current?.core;
|
||||
if (!core?.onProfile || !core?.setProfiler) {
|
||||
profilerSupported.value = false;
|
||||
@@ -273,6 +288,13 @@ onUnmounted(() => {
|
||||
<strong>GPU passes</strong>
|
||||
<span v-if="profile">{{ profile.milliseconds.toFixed(2) }} ms</span>
|
||||
</div>
|
||||
<div v-if="profile" class="profiler-meta">
|
||||
<span>{{ adapterInfo || profile.adapter }}</span>
|
||||
<span>
|
||||
{{ profile.canvas.width }}×{{ profile.canvas.height }} ·
|
||||
{{ profile.wallMilliseconds.toFixed(2) }} ms wall
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="profilerSupported === false">
|
||||
Timestamp queries are unavailable on this GPU.
|
||||
</p>
|
||||
@@ -522,6 +544,13 @@ canvas {
|
||||
color: #60a5fa;
|
||||
font-weight: 700;
|
||||
}
|
||||
.profiler-meta {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: -5px 0 12px;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
.profiler p {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@@ -365,6 +365,99 @@ return {
|
||||
},
|
||||
};`;
|
||||
|
||||
const benchmark = `import { ArcRotateCamera, Mesh, PBRMaterial, Scene, Texture } from "@yawn/handles";
|
||||
|
||||
const parameters = new URL(location.href).searchParams;
|
||||
const draws = Math.max(1, Math.min(512, Number(parameters.get("draws")) || 138));
|
||||
const grid = Math.max(1, Math.min(256, Number(parameters.get("grid")) || 128));
|
||||
const overdraw = parameters.has("overdraw");
|
||||
const columns = 12;
|
||||
const positions = [];
|
||||
const normals = [];
|
||||
const uvs = [];
|
||||
const indices = [];
|
||||
for (let y = 0; y <= grid; y++) {
|
||||
for (let x = 0; x <= grid; x++) {
|
||||
positions.push(x / grid * 2 - 1, y / grid * 2 - 1, 0);
|
||||
normals.push(0, 0, 1);
|
||||
uvs.push(x / grid * 32, y / grid * 32);
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < grid; y++) {
|
||||
for (let x = 0; x < grid; x++) {
|
||||
const first = y * (grid + 1) + x;
|
||||
indices.push(
|
||||
first, first + 1, first + grid + 2,
|
||||
first, first + grid + 2, first + grid + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pixels = new OffscreenCanvas(1024, 1024);
|
||||
const context = pixels.getContext("2d");
|
||||
for (let y = 0; y < 64; y++) {
|
||||
for (let x = 0; x < 64; x++) {
|
||||
context.fillStyle = (x + y) % 2 ? "#e2e8f0" : "#172554";
|
||||
context.fillRect(x * 16, y * 16, 16, 16);
|
||||
}
|
||||
}
|
||||
|
||||
const scene = new Scene(canvas, { fps: 1000, hdr: true });
|
||||
await scene.ready;
|
||||
log("Benchmark core ready; preparing geometry…");
|
||||
const camera = new ArcRotateCamera(scene, {
|
||||
targetPosition: [0, 0, 0],
|
||||
alpha: 0,
|
||||
beta: Math.PI / 2,
|
||||
radius: 3,
|
||||
aspect: canvas.width / canvas.height,
|
||||
});
|
||||
await camera.ready;
|
||||
log("Benchmark camera ready; compiling graph…");
|
||||
let material;
|
||||
let source;
|
||||
const meshes = [];
|
||||
await scene.batchGraphUpdates(async () => {
|
||||
const texture = new Texture(scene, {
|
||||
source: pixels.transferToImageBitmap(),
|
||||
format: "rgba8unorm-srgb",
|
||||
});
|
||||
await texture.ready;
|
||||
material = new PBRMaterial(scene, {
|
||||
baseColorTexture: texture,
|
||||
roughness: 0.45,
|
||||
});
|
||||
await material.ready;
|
||||
source = new Mesh(scene, {
|
||||
material,
|
||||
vertexData: { positions, normals, uvs, indices },
|
||||
});
|
||||
await source.ready;
|
||||
meshes.push(source);
|
||||
for (let index = 1; index < draws; index++) meshes.push(source.clone());
|
||||
await Promise.all(meshes.slice(1).map((mesh) => mesh.ready));
|
||||
for (let index = 0; index < meshes.length; index++) {
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
meshes[index].position = overdraw
|
||||
? [0, 0, index * 0.01]
|
||||
: [
|
||||
-1 + (column + 0.5) * 2 / columns,
|
||||
1 - (row + 0.5) * 2 / columns,
|
||||
0,
|
||||
];
|
||||
meshes[index].scale = overdraw
|
||||
? [0.9, 0.9, 1]
|
||||
: [0.9 / columns, 0.9 / columns, 1];
|
||||
}
|
||||
});
|
||||
|
||||
const triangles = draws * grid * grid * 2;
|
||||
log(\`Deterministic \${overdraw ? "overdraw" : "geometry"} benchmark: \${draws} draws, \${triangles.toLocaleString()} triangles, 1024² minified texture.\`);
|
||||
log("Open Profile and compare Forward GPU time, not page load time.");
|
||||
|
||||
return { scene, meshes, material, camera, dispose: () => scene.dispose() };`;
|
||||
|
||||
const core = `import { YawnCore } from "@yawn/core";
|
||||
|
||||
const encode = (value) => {
|
||||
@@ -403,5 +496,6 @@ export const playgrounds = {
|
||||
compute: { title: "Compute pass", code: compute },
|
||||
post: { title: "HDR post processing", code: post },
|
||||
importing: { title: "glTF import and BVH picking", code: importing },
|
||||
benchmark: { title: "Forward benchmark", code: benchmark },
|
||||
core: { title: "Direct core graph", code: core },
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Cameras and controls
|
||||
|
||||
Every camera allocates a generic `cameras` slot and a transform node. Projection, lens, controller state, and transforms are direct SAB rows after construction.
|
||||
Every camera allocates generic `cameras`, `cameraMatrices`, and transform rows. Camera handles precompute the view-projection matrix when lens or transform values change, so the forward vertex shader performs four dot products instead of rebuilding the camera projection for every vertex.
|
||||
|
||||
## Shared lens and projection controls
|
||||
|
||||
@@ -72,7 +72,7 @@ follow.stop();
|
||||
follow.start();
|
||||
```
|
||||
|
||||
The input and follow loops never post camera updates to core: they read and mutate the same camera, position, and quaternion rows that any other worker can use.
|
||||
The input and follow loops never post camera updates to core: they mutate the position, quaternion, camera, and derived matrix rows directly in shared memory.
|
||||
|
||||
<Playground example="cameras" />
|
||||
|
||||
|
||||
+3
-1
@@ -56,7 +56,9 @@ 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 playground’s **Profile** button shows the stream in a toggleable sidebar.
|
||||
`new YawnCore(canvas, { debug: true })` enables the same timestamp-query mode at startup. Timings describe physical GPU passes and actual compiled draw counts; compatible indexed draws over consecutive instances collapse into one command. The sidebar also reports canvas size and wall-clock completion time.
|
||||
|
||||
The saved **Forward benchmark** playground renders 138 logical objects and 4.5 million triangles. Add `&grid=32` to lower geometry density or `&overdraw=1` to stack the objects while diagnosing depth and fragment cost.
|
||||
|
||||
<Playground example="core" />
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ 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 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.
|
||||
Creating or removing a `Texture` rebuilds the single graph loadout so the GPU resource is allocated up front. The addon generates and transfers a complete mip chain by default; pass `mipmaps: false` only for data that must remain single-level. Compatible loadout rebuilds reuse the allocated GPU texture without uploading it again.
|
||||
|
||||
## Custom WGSL
|
||||
|
||||
|
||||
Reference in New Issue
Block a user