From b8390c38e1496da06227f764127343bb4fb00837 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 14 May 2026 21:44:50 -0700 Subject: [PATCH 001/109] feat(pbr-ibl): pbr-ibl working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @adobe/data-graphics and data-graphics-samples packages with a full PBR IBL renderer (split-sum, BRDF LUT, prefiltered env, irradiance). Key fix: BRDF LUT was all-zeros because importance_sample_ggx degenerates when N=(0,0,1) — cross product produces (0,0,0) whose normalization is NaN, so every sample was silently rejected. Fixed by computing H directly in tangent space in brdf-lut.ts, bypassing the TBN rotation. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/deploy-docs.yml | 8 + .vscode/settings.json | 3 +- package.json | 3 +- packages/data-graphics-samples/index.html | 25 ++ packages/data-graphics-samples/package.json | 24 ++ packages/data-graphics-samples/src/main.ts | 9 + .../sample-container-element.ts | 46 +++ .../src/sample-container/sample-container.ts | 9 + .../hello-triangle/hello-triangle-element.ts | 29 ++ .../hello-triangle/hello-triangle-service.ts | 74 +++++ .../samples/hello-triangle/hello-triangle.ts | 9 + .../metal-rough-spheres-element.ts | 103 +++++++ .../metal-rough-spheres.ts | 9 + .../pbr-model-ibl/pbr-model-ibl-element.ts | 103 +++++++ .../pbr-model-ibl/pbr-model-ibl-service.ts | 64 +++++ .../samples/pbr-model-ibl/pbr-model-ibl.ts | 9 + .../samples/pbr-model/pbr-model-element.ts | 68 +++++ .../samples/pbr-model/pbr-model-service.ts | 49 ++++ .../src/samples/pbr-model/pbr-model.ts | 9 + packages/data-graphics-samples/tsconfig.json | 17 ++ packages/data-graphics-samples/vite.config.ts | 19 ++ packages/data-graphics/LICENSE | 21 ++ packages/data-graphics/package.json | 31 +++ packages/data-graphics/src/index.ts | 11 + .../src/pbr/bind-group-layouts.ts | 45 +++ .../src/pbr/gltf/accessor-view.ts | 53 ++++ .../src/pbr/gltf/build-material-bind-group.ts | 110 ++++++++ .../src/pbr/gltf/compute-world-matrices.ts | 41 +++ .../src/pbr/gltf/decode-images.ts | 93 +++++++ .../data-graphics/src/pbr/gltf/gltf-types.ts | 132 +++++++++ .../src/pbr/gltf/load-gltf-model.ts | 132 +++++++++ .../src/pbr/gltf/pack-vertex-buffer.ts | 86 ++++++ .../data-graphics/src/pbr/gltf/parse-glb.ts | 51 ++++ .../data-graphics/src/pbr/ibl/brdf-lut.ts | 101 +++++++ .../src/pbr/ibl/build-ibl-resources.ts | 57 ++++ .../data-graphics/src/pbr/ibl/environment.ts | 190 +++++++++++++ .../src/pbr/ibl/float-to-half.ts | 35 +++ .../src/pbr/ibl/ibl-math.wgsl.ts | 60 ++++ .../data-graphics/src/pbr/ibl/irradiance.ts | 105 +++++++ .../data-graphics/src/pbr/ibl/parse-hdr.ts | 108 +++++++ .../data-graphics/src/pbr/ibl/prefilter.ts | 121 ++++++++ .../src/pbr/ibl/render-helpers.ts | 47 ++++ packages/data-graphics/src/pbr/index.ts | 10 + .../src/pbr/plugins/brdf.wgsl.ts | 43 +++ .../src/pbr/plugins/direct-shader.wgsl.ts | 111 ++++++++ .../src/pbr/plugins/ibl-shader.wgsl.ts | 127 +++++++++ .../data-graphics/src/pbr/plugins/pbr-core.ts | 46 +++ .../src/pbr/plugins/pbr-direct.ts | 102 +++++++ .../data-graphics/src/pbr/plugins/pbr-ibl.ts | 263 ++++++++++++++++++ .../src/pbr/plugins/skybox-shader.wgsl.ts | 51 ++++ .../pbr/types/pbr-material/pbr-material.ts | 8 + .../src/pbr/types/pbr-material/public.ts | 3 + .../src/pbr/types/pbr-material/schema.ts | 25 ++ .../src/pbr/types/standard-vertex/layout.ts | 19 ++ .../src/pbr/types/standard-vertex/public.ts | 4 + .../src/pbr/types/standard-vertex/schema.ts | 17 ++ .../types/standard-vertex/standard-vertex.ts | 8 + packages/data-graphics/src/plugins/camera.ts | 37 +++ .../src/plugins/default-scene-uniforms.ts | 60 ++++ .../data-graphics/src/plugins/graphics.ts | 126 +++++++++ packages/data-graphics/src/plugins/node.ts | 14 + .../data-graphics/src/types/camera/camera.ts | 8 + .../data-graphics/src/types/camera/public.ts | 5 + .../data-graphics/src/types/camera/schema.ts | 21 ++ .../src/types/camera/screen-to-world-ray.ts | 39 +++ .../src/types/camera/to-view-projection.ts | 29 ++ .../src/types/scene-uniforms/public.ts | 3 + .../types/scene-uniforms/scene-uniforms.ts | 15 + .../src/types/scene-uniforms/schema.ts | 17 ++ .../vertices/position-color-normal/layout.ts | 16 ++ .../position-color-normal.ts | 8 + .../vertices/position-color-normal/public.ts | 4 + .../vertices/position-color-normal/schema.ts | 16 ++ packages/data-graphics/tsconfig.json | 25 ++ packages/data-p2p-tictactoe/package.json | 3 +- packages/data-p2p-tictactoe/vite.config.ts | 2 + packages/data-react-hello/package.json | 5 +- packages/data-react-hello/vite.config.ts | 3 +- packages/data-solid-dashboard/package.json | 3 +- packages/data-solid-dashboard/vite.config.ts | 3 +- packages/data/src/math/mat4x4/functions.ts | 9 +- packages/data/src/schema/index.ts | 2 + .../data/src/typed-buffer/structs/index.ts | 1 + .../structs/wgsl-struct-fields.ts | 57 ++++ pnpm-lock.yaml | 53 ++++ pnpm-workspace.yaml | 2 + 86 files changed, 3731 insertions(+), 11 deletions(-) create mode 100644 packages/data-graphics-samples/index.html create mode 100644 packages/data-graphics-samples/package.json create mode 100644 packages/data-graphics-samples/src/main.ts create mode 100644 packages/data-graphics-samples/src/sample-container/sample-container-element.ts create mode 100644 packages/data-graphics-samples/src/sample-container/sample-container.ts create mode 100644 packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle-element.ts create mode 100644 packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle-service.ts create mode 100644 packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle.ts create mode 100644 packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres-element.ts create mode 100644 packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres.ts create mode 100644 packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-element.ts create mode 100644 packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-service.ts create mode 100644 packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl.ts create mode 100644 packages/data-graphics-samples/src/samples/pbr-model/pbr-model-element.ts create mode 100644 packages/data-graphics-samples/src/samples/pbr-model/pbr-model-service.ts create mode 100644 packages/data-graphics-samples/src/samples/pbr-model/pbr-model.ts create mode 100644 packages/data-graphics-samples/tsconfig.json create mode 100644 packages/data-graphics-samples/vite.config.ts create mode 100644 packages/data-graphics/LICENSE create mode 100644 packages/data-graphics/package.json create mode 100644 packages/data-graphics/src/index.ts create mode 100644 packages/data-graphics/src/pbr/bind-group-layouts.ts create mode 100644 packages/data-graphics/src/pbr/gltf/accessor-view.ts create mode 100644 packages/data-graphics/src/pbr/gltf/build-material-bind-group.ts create mode 100644 packages/data-graphics/src/pbr/gltf/compute-world-matrices.ts create mode 100644 packages/data-graphics/src/pbr/gltf/decode-images.ts create mode 100644 packages/data-graphics/src/pbr/gltf/gltf-types.ts create mode 100644 packages/data-graphics/src/pbr/gltf/load-gltf-model.ts create mode 100644 packages/data-graphics/src/pbr/gltf/pack-vertex-buffer.ts create mode 100644 packages/data-graphics/src/pbr/gltf/parse-glb.ts create mode 100644 packages/data-graphics/src/pbr/ibl/brdf-lut.ts create mode 100644 packages/data-graphics/src/pbr/ibl/build-ibl-resources.ts create mode 100644 packages/data-graphics/src/pbr/ibl/environment.ts create mode 100644 packages/data-graphics/src/pbr/ibl/float-to-half.ts create mode 100644 packages/data-graphics/src/pbr/ibl/ibl-math.wgsl.ts create mode 100644 packages/data-graphics/src/pbr/ibl/irradiance.ts create mode 100644 packages/data-graphics/src/pbr/ibl/parse-hdr.ts create mode 100644 packages/data-graphics/src/pbr/ibl/prefilter.ts create mode 100644 packages/data-graphics/src/pbr/ibl/render-helpers.ts create mode 100644 packages/data-graphics/src/pbr/index.ts create mode 100644 packages/data-graphics/src/pbr/plugins/brdf.wgsl.ts create mode 100644 packages/data-graphics/src/pbr/plugins/direct-shader.wgsl.ts create mode 100644 packages/data-graphics/src/pbr/plugins/ibl-shader.wgsl.ts create mode 100644 packages/data-graphics/src/pbr/plugins/pbr-core.ts create mode 100644 packages/data-graphics/src/pbr/plugins/pbr-direct.ts create mode 100644 packages/data-graphics/src/pbr/plugins/pbr-ibl.ts create mode 100644 packages/data-graphics/src/pbr/plugins/skybox-shader.wgsl.ts create mode 100644 packages/data-graphics/src/pbr/types/pbr-material/pbr-material.ts create mode 100644 packages/data-graphics/src/pbr/types/pbr-material/public.ts create mode 100644 packages/data-graphics/src/pbr/types/pbr-material/schema.ts create mode 100644 packages/data-graphics/src/pbr/types/standard-vertex/layout.ts create mode 100644 packages/data-graphics/src/pbr/types/standard-vertex/public.ts create mode 100644 packages/data-graphics/src/pbr/types/standard-vertex/schema.ts create mode 100644 packages/data-graphics/src/pbr/types/standard-vertex/standard-vertex.ts create mode 100644 packages/data-graphics/src/plugins/camera.ts create mode 100644 packages/data-graphics/src/plugins/default-scene-uniforms.ts create mode 100644 packages/data-graphics/src/plugins/graphics.ts create mode 100644 packages/data-graphics/src/plugins/node.ts create mode 100644 packages/data-graphics/src/types/camera/camera.ts create mode 100644 packages/data-graphics/src/types/camera/public.ts create mode 100644 packages/data-graphics/src/types/camera/schema.ts create mode 100644 packages/data-graphics/src/types/camera/screen-to-world-ray.ts create mode 100644 packages/data-graphics/src/types/camera/to-view-projection.ts create mode 100644 packages/data-graphics/src/types/scene-uniforms/public.ts create mode 100644 packages/data-graphics/src/types/scene-uniforms/scene-uniforms.ts create mode 100644 packages/data-graphics/src/types/scene-uniforms/schema.ts create mode 100644 packages/data-graphics/src/types/vertices/position-color-normal/layout.ts create mode 100644 packages/data-graphics/src/types/vertices/position-color-normal/position-color-normal.ts create mode 100644 packages/data-graphics/src/types/vertices/position-color-normal/public.ts create mode 100644 packages/data-graphics/src/types/vertices/position-color-normal/schema.ts create mode 100644 packages/data-graphics/tsconfig.json create mode 100644 packages/data/src/typed-buffer/structs/wgsl-struct-fields.ts diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 45344d19..a6eb377d 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -40,12 +40,20 @@ jobs: - run: pnpm --filter @adobe/data-lit build + - run: pnpm --filter @adobe/data-graphics build + - run: pnpm --filter data-p2p-tictactoe build env: CI: true - run: cp -r packages/data-p2p-tictactoe/dist packages/data/docs/p2p-tictactoe + - run: pnpm --filter data-graphics-samples build + env: + CI: true + + - run: cp -r packages/data-graphics-samples/dist packages/data/docs/graphics-samples + - uses: actions/configure-pages@v5 with: enablement: true diff --git a/.vscode/settings.json b/.vscode/settings.json index 8252fca4..4c70df1c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "typescript.tsdk": "${workspaceFolder}/node_modules/.pnpm/node_modules/typescript" + "typescript.tsdk": "node_modules/.pnpm/node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true } diff --git a/package.json b/package.json index 78625911..089a249a 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "test": "pnpm -r run test", "dev": "pnpm -r --parallel run dev", "dev:data": "pnpm --filter @adobe/data run dev", + "dev-graphics": "pnpm --parallel --filter @adobe/data --filter @adobe/data-graphics --filter data-graphics-samples run dev", "link": "pnpm -r --filter @adobe/data* run link", - "publish": "sh -c 'for x in \"$@\"; do OTP=\"$x\"; done; export NPM_CONFIG_OTP=\"$OTP\"; pnpm -r --filter @adobe/data --filter @adobe/data-react --filter @adobe/data-lit --filter @adobe/data-solid run publish-public' sh", + "publish": "sh -c 'for x in \"$@\"; do OTP=\"$x\"; done; export NPM_CONFIG_OTP=\"$OTP\"; pnpm -r --filter @adobe/data --filter @adobe/data-react --filter @adobe/data-lit --filter @adobe/data-solid --filter @adobe/data-graphics run publish-public' sh", "bump": "pnpm version patch --no-git-tag-version && V=$(node -p \"require('$PWD/package.json').version\") && pnpm -r exec pnpm version $V --no-git-tag-version --allow-same-version", "release": "pnpm bump && pnpm publish", "bp": "pnpm bump && pnpm run publish" diff --git a/packages/data-graphics-samples/index.html b/packages/data-graphics-samples/index.html new file mode 100644 index 00000000..58e0d485 --- /dev/null +++ b/packages/data-graphics-samples/index.html @@ -0,0 +1,25 @@ + + + + + + Data Graphics Samples + + + +
+ + + diff --git a/packages/data-graphics-samples/package.json b/packages/data-graphics-samples/package.json new file mode 100644 index 00000000..1e140465 --- /dev/null +++ b/packages/data-graphics-samples/package.json @@ -0,0 +1,24 @@ +{ + "name": "data-graphics-samples", + "version": "0.9.56", + "description": "WebGPU graphics samples built on @adobe/data-graphics", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "dev:all": "pnpm --parallel --filter @adobe/data --filter @adobe/data-graphics --filter data-graphics-samples run dev", + "build": "vite build" + }, + "dependencies": { + "@adobe/data": "workspace:*", + "@adobe/data-graphics": "workspace:*", + "@adobe/data-lit": "workspace:*", + "lit": "^3.3.1" + }, + "devDependencies": { + "@webgpu/types": "^0.1.61", + "typescript": "^5.8.3", + "vite": "^5.1.1", + "vite-plugin-checker": "^0.12.0" + } +} diff --git a/packages/data-graphics-samples/src/main.ts b/packages/data-graphics-samples/src/main.ts new file mode 100644 index 00000000..36913db0 --- /dev/null +++ b/packages/data-graphics-samples/src/main.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { render } from "lit"; +import { SampleContainer } from "./sample-container/sample-container.js"; + +const app = document.getElementById("app"); +if (app) { + render(SampleContainer(), app); +} diff --git a/packages/data-graphics-samples/src/sample-container/sample-container-element.ts b/packages/data-graphics-samples/src/sample-container/sample-container-element.ts new file mode 100644 index 00000000..b2263c18 --- /dev/null +++ b/packages/data-graphics-samples/src/sample-container/sample-container-element.ts @@ -0,0 +1,46 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { LitElement, html, css } from "lit"; +import { customElement } from "lit/decorators.js"; +import { HelloTriangle } from "../samples/hello-triangle/hello-triangle.js"; +import { PbrModel } from "../samples/pbr-model/pbr-model.js"; +import { PbrModelIbl } from "../samples/pbr-model-ibl/pbr-model-ibl.js"; +import { MetalRoughSpheres } from "../samples/metal-rough-spheres/metal-rough-spheres.js"; + +const tagName = "sample-container"; + +declare global { + interface HTMLElementTagNameMap { + [tagName]: SampleContainerElement; + } +} + +@customElement(tagName) +export class SampleContainerElement extends LitElement { + static styles = css` + :host { display: block; width: 100%; } + nav { display: flex; gap: 0.5rem; padding: 1rem; flex-wrap: wrap; } + a { color: #7af; text-decoration: none; padding: 0.25rem 0.75rem; border: 1px solid #7af; border-radius: 4px; } + a:hover { background: #7af2; } + .content { padding: 1rem; } + `; + + private get sample(): string { + return new URLSearchParams(window.location.search).get("sample") ?? "hello-triangle"; + } + + override render() { + const samples = ["hello-triangle", "pbr-model", "pbr-model-ibl", "metal-rough-spheres"]; + return html` + +
+ ${this.sample === "hello-triangle" ? HelloTriangle() : ""} + ${this.sample === "pbr-model" ? PbrModel() : ""} + ${this.sample === "pbr-model-ibl" ? PbrModelIbl() : ""} + ${this.sample === "metal-rough-spheres" ? MetalRoughSpheres() : ""} +
+ `; + } +} diff --git a/packages/data-graphics-samples/src/sample-container/sample-container.ts b/packages/data-graphics-samples/src/sample-container/sample-container.ts new file mode 100644 index 00000000..f5502b34 --- /dev/null +++ b/packages/data-graphics-samples/src/sample-container/sample-container.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { html } from "lit"; +import type { TemplateResult } from "lit"; + +export const SampleContainer = (): TemplateResult => { + void import("./sample-container-element.js"); + return html``; +}; diff --git a/packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle-element.ts b/packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle-element.ts new file mode 100644 index 00000000..5011cce8 --- /dev/null +++ b/packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle-element.ts @@ -0,0 +1,29 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { LitElement, html, css } from "lit"; +import { customElement } from "lit/decorators.js"; +import { createHelloTriangleService } from "./hello-triangle-service.js"; + +const tagName = "hello-triangle"; + +declare global { + interface HTMLElementTagNameMap { + [tagName]: HelloTriangleElement; + } +} + +@customElement(tagName) +export class HelloTriangleElement extends LitElement { + static styles = css`:host { display: block; } canvas { display: block; border: 1px solid #333; }`; + + private service = createHelloTriangleService(); + + override firstUpdated() { + const canvas = this.renderRoot.querySelector("canvas"); + if (canvas) this.service.transactions.setCanvas(canvas); + } + + override render() { + return html``; + } +} diff --git a/packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle-service.ts b/packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle-service.ts new file mode 100644 index 00000000..acdd4758 --- /dev/null +++ b/packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle-service.ts @@ -0,0 +1,74 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; +import { graphics } from "@adobe/data-graphics"; + +const triangleShader = ` +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) color: vec4f, +} + +@vertex +fn vs_main(@builtin(vertex_index) i: u32) -> VertexOutput { + var pos = array( + vec2f( 0.0, 0.5), + vec2f(-0.5, -0.5), + vec2f( 0.5, -0.5), + ); + var colors = array( + vec4f(1.0, 0.0, 0.0, 1.0), + vec4f(0.0, 1.0, 0.0, 1.0), + vec4f(0.0, 0.0, 1.0, 1.0), + ); + var out: VertexOutput; + out.position = vec4f(pos[i], 0.0, 1.0); + out.color = colors[i]; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4f { + return in.color; +} +`; + +export function createHelloTriangleService() { + return Database.create( + Database.Plugin.create({ + extends: graphics, + systems: { + hello_triangle_render: { + create: db => { + let pipeline: GPURenderPipeline | null = null; + return () => { + const { device, renderPassEncoder, canvasFormat, depthFormat } = db.store.resources; + if (!device || !renderPassEncoder) return; + + if (!pipeline) { + const module = device.createShaderModule({ code: triangleShader }); + pipeline = device.createRenderPipeline({ + layout: "auto", + vertex: { module, entryPoint: "vs_main" }, + fragment: { module, entryPoint: "fs_main", targets: [{ format: canvasFormat }] }, + primitive: { topology: "triangle-list" }, + depthStencil: { + format: depthFormat, + depthWriteEnabled: true, + depthCompare: "less", + }, + }); + } + + renderPassEncoder.setPipeline(pipeline); + renderPassEncoder.draw(3); + }; + }, + schedule: { during: ["render"] } + }, + }, + }) + ); +} + +export type HelloTriangleService = ReturnType; diff --git a/packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle.ts b/packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle.ts new file mode 100644 index 00000000..9e190e50 --- /dev/null +++ b/packages/data-graphics-samples/src/samples/hello-triangle/hello-triangle.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { html } from "lit"; +import type { TemplateResult } from "lit"; + +export const HelloTriangle = (): TemplateResult => { + void import("./hello-triangle-element.js"); + return html``; +}; diff --git a/packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres-element.ts b/packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres-element.ts new file mode 100644 index 00000000..6fe9a59d --- /dev/null +++ b/packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres-element.ts @@ -0,0 +1,103 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import type { Vec3 } from "@adobe/data/math"; +import { loadGltfModel } from "@adobe/data-graphics"; +import { createPbrModelIblService } from "../pbr-model-ibl/pbr-model-ibl-service.js"; + +const tagName = "metal-rough-spheres"; + +declare global { + interface HTMLElementTagNameMap { + [tagName]: MetalRoughSpheresElement; + } +} + +const BASE = import.meta.env.BASE_URL ?? "/"; +const MODEL_URL = `${BASE}models/MetalRoughSpheres.glb`; +const ENV_URL = `${BASE}env/venice_sunset_1k.hdr`; + +@customElement(tagName) +export class MetalRoughSpheresElement extends LitElement { + static styles = css` + :host { display: block; } + .stage { position: relative; } + canvas { display: block; border: 1px solid #333; background: #111; cursor: grab; } + canvas.dragging { cursor: grabbing; } + .status { position: absolute; bottom: 0.5rem; left: 0.5rem; padding: 0.25rem 0.5rem; background: rgba(0,0,0,0.6); color: #ddd; font: 12px/1 ui-monospace, monospace; border-radius: 4px; } + .hint { position: absolute; top: 0.5rem; right: 0.5rem; padding: 0.25rem 0.5rem; background: rgba(0,0,0,0.5); color: #aaa; font: 11px/1 ui-monospace, monospace; border-radius: 4px; } + `; + + private service = createPbrModelIblService(); + @state() private status = "loading…"; + private dragLastX = 0; + private dragging = false; + + private onPointerDown = (e: PointerEvent) => { + const canvas = e.currentTarget as HTMLCanvasElement; + canvas.setPointerCapture(e.pointerId); + canvas.classList.add("dragging"); + this.dragging = true; + this.dragLastX = e.clientX; + }; + + private onPointerMove = (e: PointerEvent) => { + if (!this.dragging) return; + const dx = e.clientX - this.dragLastX; + this.dragLastX = e.clientX; + this.service.transactions.addOrbitAngle(-dx * 0.01); + }; + + private onPointerUp = (e: PointerEvent) => { + const canvas = e.currentTarget as HTMLCanvasElement; + canvas.releasePointerCapture(e.pointerId); + canvas.classList.remove("dragging"); + this.dragging = false; + }; + + override async firstUpdated() { + const canvas = this.renderRoot.querySelector("canvas"); + if (!canvas) return; + canvas.addEventListener("pointerdown", this.onPointerDown); + canvas.addEventListener("pointermove", this.onPointerMove); + canvas.addEventListener("pointerup", this.onPointerUp); + canvas.addEventListener("pointercancel", this.onPointerUp); + this.service.transactions.setCanvas(canvas); + this.service.transactions.setIblEnvironmentUrl(ENV_URL); + this.service.transactions.setLight({ color: [0, 0, 0] }); + + try { + const loaded = await loadGltfModel(this.service, MODEL_URL); + const center: Vec3 = [ + (loaded.boundsMin[0] + loaded.boundsMax[0]) / 2, + (loaded.boundsMin[1] + loaded.boundsMax[1]) / 2, + (loaded.boundsMin[2] + loaded.boundsMax[2]) / 2, + ]; + const size = Math.max( + loaded.boundsMax[0] - loaded.boundsMin[0], + loaded.boundsMax[1] - loaded.boundsMin[1], + loaded.boundsMax[2] - loaded.boundsMin[2], + ); + this.service.transactions.setOrbit({ + center, + radius: size * 1.2, + height: 0, + }); + this.status = `${loaded.primitiveCount} primitive(s) · IBL`; + } catch (e) { + console.error("MetalRoughSpheres load failed", e); + this.status = `error: ${(e as Error).message}`; + } + } + + override render() { + return html` +
+ +
${this.status}
+
drag to orbit
+
+ `; + } +} diff --git a/packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres.ts b/packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres.ts new file mode 100644 index 00000000..6761faa7 --- /dev/null +++ b/packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { html } from "lit"; +import type { TemplateResult } from "lit"; + +export const MetalRoughSpheres = (): TemplateResult => { + void import("./metal-rough-spheres-element.js"); + return html``; +}; diff --git a/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-element.ts b/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-element.ts new file mode 100644 index 00000000..8008bfb9 --- /dev/null +++ b/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-element.ts @@ -0,0 +1,103 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import type { Vec3 } from "@adobe/data/math"; +import { loadGltfModel } from "@adobe/data-graphics"; +import { createPbrModelIblService } from "./pbr-model-ibl-service.js"; + +const tagName = "pbr-model-ibl"; + +declare global { + interface HTMLElementTagNameMap { + [tagName]: PbrModelIblElement; + } +} + +const BASE = import.meta.env.BASE_URL ?? "/"; +const MODEL_URL = `${BASE}models/DamagedHelmet.glb`; +const ENV_URL = `${BASE}env/studio_small_09_1k.hdr`; + +@customElement(tagName) +export class PbrModelIblElement extends LitElement { + static styles = css` + :host { display: block; } + .stage { position: relative; } + canvas { display: block; border: 1px solid #333; background: #111; cursor: grab; } + canvas.dragging { cursor: grabbing; } + .status { position: absolute; bottom: 0.5rem; left: 0.5rem; padding: 0.25rem 0.5rem; background: rgba(0,0,0,0.6); color: #ddd; font: 12px/1 ui-monospace, monospace; border-radius: 4px; } + .hint { position: absolute; top: 0.5rem; right: 0.5rem; padding: 0.25rem 0.5rem; background: rgba(0,0,0,0.5); color: #aaa; font: 11px/1 ui-monospace, monospace; border-radius: 4px; } + `; + + private service = createPbrModelIblService(); + @state() private status = "loading…"; + private dragLastX = 0; + private dragging = false; + + private onPointerDown = (e: PointerEvent) => { + const canvas = e.currentTarget as HTMLCanvasElement; + canvas.setPointerCapture(e.pointerId); + canvas.classList.add("dragging"); + this.dragging = true; + this.dragLastX = e.clientX; + }; + + private onPointerMove = (e: PointerEvent) => { + if (!this.dragging) return; + const dx = e.clientX - this.dragLastX; + this.dragLastX = e.clientX; + this.service.transactions.addOrbitAngle(-dx * 0.01); + }; + + private onPointerUp = (e: PointerEvent) => { + const canvas = e.currentTarget as HTMLCanvasElement; + canvas.releasePointerCapture(e.pointerId); + canvas.classList.remove("dragging"); + this.dragging = false; + }; + + override async firstUpdated() { + const canvas = this.renderRoot.querySelector("canvas"); + if (!canvas) return; + canvas.addEventListener("pointerdown", this.onPointerDown); + canvas.addEventListener("pointermove", this.onPointerMove); + canvas.addEventListener("pointerup", this.onPointerUp); + canvas.addEventListener("pointercancel", this.onPointerUp); + this.service.transactions.setCanvas(canvas); + this.service.transactions.setIblEnvironmentUrl(ENV_URL); + this.service.transactions.setLight({ color: [0.4, 0.4, 0.4] }); + + try { + const loaded = await loadGltfModel(this.service, MODEL_URL); + const center: Vec3 = [ + (loaded.boundsMin[0] + loaded.boundsMax[0]) / 2, + (loaded.boundsMin[1] + loaded.boundsMax[1]) / 2, + (loaded.boundsMin[2] + loaded.boundsMax[2]) / 2, + ]; + const size = Math.max( + loaded.boundsMax[0] - loaded.boundsMin[0], + loaded.boundsMax[1] - loaded.boundsMin[1], + loaded.boundsMax[2] - loaded.boundsMin[2], + ); + this.service.transactions.setOrbit({ + center, + radius: size * 1.6, + height: size * 0.25, + }); + this.status = `${loaded.primitiveCount} primitive(s) · IBL`; + } catch (e) { + console.error("PBR-IBL model load failed", e); + this.status = `error: ${(e as Error).message}`; + } + } + + override render() { + return html` +
+ +
${this.status}
+
drag to orbit
+
+ `; + } +} diff --git a/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-service.ts b/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-service.ts new file mode 100644 index 00000000..fc532ee4 --- /dev/null +++ b/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-service.ts @@ -0,0 +1,64 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; +import { F32, Vec3 } from "@adobe/data/math"; +import { pbrIbl } from "@adobe/data-graphics"; + +export const pbrModelIblPlugin = Database.Plugin.create({ + extends: pbrIbl, + resources: { + orbitCenter: { default: [0, 0, 0] as Vec3, transient: true }, + orbitRadius: { default: 3 as F32, transient: true }, + orbitHeight: { default: 0 as F32, transient: true }, + orbitAngle: { default: 0 as F32, transient: true }, + orbitAutoSpin: { default: true as boolean, transient: true }, + }, + transactions: { + setOrbit(t, args: { center: Vec3; radius: number; height: number }) { + t.resources.orbitCenter = args.center; + t.resources.orbitRadius = args.radius; + t.resources.orbitHeight = args.height; + }, + addOrbitAngle(t, delta: number) { + t.resources.orbitAngle = (t.resources.orbitAngle + delta) as F32; + t.resources.orbitAutoSpin = false; + }, + resumeAutoSpin(t) { + t.resources.orbitAutoSpin = true; + }, + }, + systems: { + orbitCamera: { + create: db => { + const start = performance.now(); + let lastTime = start; + return () => { + const { orbitCenter, orbitRadius, orbitHeight, orbitAutoSpin, camera } = db.store.resources; + if (!camera) return; + const now = performance.now(); + if (orbitAutoSpin) { + const dt = (now - lastTime) / 1000; + db.store.resources.orbitAngle = (db.store.resources.orbitAngle + dt * 1.0) as F32; + } + lastTime = now; + const angle = db.store.resources.orbitAngle; + const x = orbitCenter[0] + Math.sin(angle) * orbitRadius; + const z = orbitCenter[2] + Math.cos(angle) * orbitRadius; + const y = orbitCenter[1] + orbitHeight; + db.store.resources.camera = { + ...camera, + position: [x, y, z], + target: orbitCenter, + }; + }; + }, + schedule: { during: ["update"] } + } + } +}); + +export function createPbrModelIblService() { + return Database.create(pbrModelIblPlugin); +} + +export type PbrModelIblService = ReturnType; diff --git a/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl.ts b/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl.ts new file mode 100644 index 00000000..92b3a216 --- /dev/null +++ b/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { html } from "lit"; +import type { TemplateResult } from "lit"; + +export const PbrModelIbl = (): TemplateResult => { + void import("./pbr-model-ibl-element.js"); + return html``; +}; diff --git a/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-element.ts b/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-element.ts new file mode 100644 index 00000000..76e6eac0 --- /dev/null +++ b/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-element.ts @@ -0,0 +1,68 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import type { Vec3 } from "@adobe/data/math"; +import { loadGltfModel } from "@adobe/data-graphics"; +import { createPbrModelService } from "./pbr-model-service.js"; + +const tagName = "pbr-model"; + +declare global { + interface HTMLElementTagNameMap { + [tagName]: PbrModelElement; + } +} + +const MODEL_URL = `${import.meta.env.BASE_URL ?? "/"}models/DamagedHelmet.glb`; + +@customElement(tagName) +export class PbrModelElement extends LitElement { + static styles = css` + :host { display: block; } + .stage { position: relative; } + canvas { display: block; border: 1px solid #333; background: #111; } + .status { position: absolute; bottom: 0.5rem; left: 0.5rem; padding: 0.25rem 0.5rem; background: rgba(0,0,0,0.6); color: #ddd; font: 12px/1 ui-monospace, monospace; border-radius: 4px; } + `; + + private service = createPbrModelService(); + @state() private status = "loading…"; + + override async firstUpdated() { + const canvas = this.renderRoot.querySelector("canvas"); + if (!canvas) return; + this.service.transactions.setCanvas(canvas); + + try { + const loaded = await loadGltfModel(this.service, MODEL_URL); + const center: Vec3 = [ + (loaded.boundsMin[0] + loaded.boundsMax[0]) / 2, + (loaded.boundsMin[1] + loaded.boundsMax[1]) / 2, + (loaded.boundsMin[2] + loaded.boundsMax[2]) / 2, + ]; + const size = Math.max( + loaded.boundsMax[0] - loaded.boundsMin[0], + loaded.boundsMax[1] - loaded.boundsMin[1], + loaded.boundsMax[2] - loaded.boundsMin[2], + ); + this.service.transactions.setOrbit({ + center, + radius: size * 1.6, + height: size * 0.25, + }); + this.status = `${loaded.primitiveCount} primitive(s)`; + } catch (e) { + console.error("PBR model load failed", e); + this.status = `error: ${(e as Error).message}`; + } + } + + override render() { + return html` +
+ +
${this.status}
+
+ `; + } +} diff --git a/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-service.ts b/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-service.ts new file mode 100644 index 00000000..54d39732 --- /dev/null +++ b/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-service.ts @@ -0,0 +1,49 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; +import { F32, Vec3 } from "@adobe/data/math"; +import { pbrDirect } from "@adobe/data-graphics"; + +export const pbrModelPlugin = Database.Plugin.create({ + extends: pbrDirect, + resources: { + orbitCenter: { default: [0, 0, 0] as Vec3, transient: true }, + orbitRadius: { default: 3 as F32, transient: true }, + orbitHeight: { default: 0 as F32, transient: true }, + }, + transactions: { + setOrbit(t, args: { center: Vec3; radius: number; height: number }) { + t.resources.orbitCenter = args.center; + t.resources.orbitRadius = args.radius; + t.resources.orbitHeight = args.height; + }, + }, + systems: { + orbitCamera: { + create: db => { + const start = performance.now(); + return () => { + const { orbitCenter, orbitRadius, orbitHeight, camera } = db.store.resources; + if (!camera) return; + const t = (performance.now() - start) / 1000; + const angle = t * 0.5; + const x = orbitCenter[0] + Math.sin(angle) * orbitRadius; + const z = orbitCenter[2] + Math.cos(angle) * orbitRadius; + const y = orbitCenter[1] + orbitHeight; + db.store.resources.camera = { + ...camera, + position: [x, y, z], + target: orbitCenter, + }; + }; + }, + schedule: { during: ["update"] } + } + } +}); + +export function createPbrModelService() { + return Database.create(pbrModelPlugin); +} + +export type PbrModelService = ReturnType; diff --git a/packages/data-graphics-samples/src/samples/pbr-model/pbr-model.ts b/packages/data-graphics-samples/src/samples/pbr-model/pbr-model.ts new file mode 100644 index 00000000..6f65c023 --- /dev/null +++ b/packages/data-graphics-samples/src/samples/pbr-model/pbr-model.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { html } from "lit"; +import type { TemplateResult } from "lit"; + +export const PbrModel = (): TemplateResult => { + void import("./pbr-model-element.js"); + return html``; +}; diff --git a/packages/data-graphics-samples/tsconfig.json b/packages/data-graphics-samples/tsconfig.json new file mode 100644 index 00000000..3d7c9571 --- /dev/null +++ b/packages/data-graphics-samples/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": false, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "types": ["@webgpu/types", "vite/client"], + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "experimentalDecorators": true, + "strict": true + }, + "include": ["src"] +} diff --git a/packages/data-graphics-samples/vite.config.ts b/packages/data-graphics-samples/vite.config.ts new file mode 100644 index 00000000..cff54b38 --- /dev/null +++ b/packages/data-graphics-samples/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite"; +import checker from "vite-plugin-checker"; + +export default defineConfig({ + plugins: [checker({ typescript: true })], + optimizeDeps: { + esbuildOptions: { + tsconfigRaw: { + compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false }, + }, + }, + }, + root: ".", + // In production (GitHub Pages) the app lives at /data/graphics-samples/. + // The dev server ignores this — it always serves from /. + base: process.env.CI ? "/data/graphics-samples/" : "/", + build: { outDir: "dist" }, + server: { port: 3008, open: false }, +}); diff --git a/packages/data-graphics/LICENSE b/packages/data-graphics/LICENSE new file mode 100644 index 00000000..4290d535 --- /dev/null +++ b/packages/data-graphics/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Adobe, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/data-graphics/package.json b/packages/data-graphics/package.json new file mode 100644 index 00000000..fd3b573c --- /dev/null +++ b/packages/data-graphics/package.json @@ -0,0 +1,31 @@ +{ + "name": "@adobe/data-graphics", + "version": "0.9.56", + "description": "Adobe data WebGPU graphics plugins and types", + "type": "module", + "private": false, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "scripts": { + "build": "cp ../../LICENSE . && tsc -b", + "dev": "tsc -b -w", + "test": "vitest --run --passWithNoTests", + "publish-public": "pnpm build && pnpm publish --no-git-checks --access public" + }, + "dependencies": { + "@adobe/data": "workspace:*" + }, + "devDependencies": { + "@webgpu/types": "^0.1.61", + "typescript": "^5.8.3", + "vitest": "^1.6.0" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + } +} diff --git a/packages/data-graphics/src/index.ts b/packages/data-graphics/src/index.ts new file mode 100644 index 00000000..ecf94f6c --- /dev/null +++ b/packages/data-graphics/src/index.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export { graphics } from "./plugins/graphics.js"; +export { camera } from "./plugins/camera.js"; +export { defaultSceneUniforms } from "./plugins/default-scene-uniforms.js"; +export { node } from "./plugins/node.js"; +export { Camera } from "./types/camera/camera.js"; +export { SceneUniforms } from "./types/scene-uniforms/scene-uniforms.js"; +export { PositionColorNormalVertex } from "./types/vertices/position-color-normal/position-color-normal.js"; + +export * from "./pbr/index.js"; diff --git a/packages/data-graphics/src/pbr/bind-group-layouts.ts b/packages/data-graphics/src/pbr/bind-group-layouts.ts new file mode 100644 index 00000000..b4a95d8d --- /dev/null +++ b/packages/data-graphics/src/pbr/bind-group-layouts.ts @@ -0,0 +1,45 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Bind group layout descriptors are stable between the renderer (pipeline creation) +// and the loader (per-material bind group creation). WebGPU compares layouts +// structurally for compatibility, so creating two layout objects from the same +// descriptor is safe. + +export function createSceneBindGroupLayout(device: GPUDevice): GPUBindGroupLayout { + return device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + buffer: { type: "uniform" }, + }, + ], + }); +} + +export function createMaterialBindGroupLayout(device: GPUDevice): GPUBindGroupLayout { + const textureEntry = (binding: number): GPUBindGroupLayoutEntry => ({ + binding, + visibility: GPUShaderStage.FRAGMENT, + texture: { sampleType: "float", viewDimension: "2d" }, + }); + return device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: { type: "uniform" }, + }, + textureEntry(1), + textureEntry(2), + textureEntry(3), + textureEntry(4), + textureEntry(5), + { + binding: 6, + visibility: GPUShaderStage.FRAGMENT, + sampler: { type: "filtering" }, + }, + ], + }); +} diff --git a/packages/data-graphics/src/pbr/gltf/accessor-view.ts b/packages/data-graphics/src/pbr/gltf/accessor-view.ts new file mode 100644 index 00000000..58c0f6ac --- /dev/null +++ b/packages/data-graphics/src/pbr/gltf/accessor-view.ts @@ -0,0 +1,53 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { + ACCESSOR_TYPE_COMPONENTS, + COMPONENT_TYPE, + type GltfAccessor, + type GltfAsset, +} from "./gltf-types.js"; + +/** + * Returns a typed-array view over the buffer slice referenced by an accessor. + * Assumes tightly packed data (no byteStride). For interleaved attributes a + * different reader would be needed — glTF primitives commonly use separate buffer + * views per attribute so this is sufficient for the standard case. + */ +export function readAccessor( + gltf: GltfAsset, + bin: ArrayBuffer, + accessorIndex: number, +): Float32Array | Uint16Array | Uint32Array | Uint8Array { + const accessor = gltf.accessors?.[accessorIndex]; + if (!accessor) throw new Error(`Accessor ${accessorIndex} not found`); + const bv = gltf.bufferViews?.[accessor.bufferView ?? -1]; + if (!bv) throw new Error(`BufferView for accessor ${accessorIndex} not found`); + + const componentCount = ACCESSOR_TYPE_COMPONENTS[accessor.type]; + const totalElements = accessor.count * componentCount; + const offset = (bv.byteOffset ?? 0) + (accessor.byteOffset ?? 0); + + switch (accessor.componentType) { + case COMPONENT_TYPE.FLOAT: + return new Float32Array(bin, offset, totalElements); + case COMPONENT_TYPE.UNSIGNED_SHORT: + return new Uint16Array(bin, offset, totalElements); + case COMPONENT_TYPE.UNSIGNED_INT: + return new Uint32Array(bin, offset, totalElements); + case COMPONENT_TYPE.UNSIGNED_BYTE: + return new Uint8Array(bin, offset, totalElements); + default: + throw new Error(`Unsupported componentType ${accessor.componentType}`); + } +} + +export function readImageBytes(gltf: GltfAsset, bin: ArrayBuffer, imageIndex: number): Uint8Array { + const image = gltf.images?.[imageIndex]; + if (!image) throw new Error(`Image ${imageIndex} not found`); + if (image.bufferView === undefined) { + throw new Error(`Image ${imageIndex} has no bufferView (external URIs not supported)`); + } + const bv = gltf.bufferViews?.[image.bufferView]; + if (!bv) throw new Error(`BufferView for image ${imageIndex} not found`); + return new Uint8Array(bin, bv.byteOffset ?? 0, bv.byteLength); +} diff --git a/packages/data-graphics/src/pbr/gltf/build-material-bind-group.ts b/packages/data-graphics/src/pbr/gltf/build-material-bind-group.ts new file mode 100644 index 00000000..1f42b8f4 --- /dev/null +++ b/packages/data-graphics/src/pbr/gltf/build-material-bind-group.ts @@ -0,0 +1,110 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { createStructBuffer, copyToGPUBuffer, getStructLayout, type TypedBuffer } from "@adobe/data/typed-buffer"; +import { schema as pbrMaterialSchema } from "../types/pbr-material/schema.js"; +import type { PbrMaterial } from "../types/pbr-material/pbr-material.js"; +import type { GltfAsset, GltfMaterial } from "./gltf-types.js"; + +const layout = getStructLayout(pbrMaterialSchema); + +export interface MaterialTextures { + baseColor: GPUTextureView; + metallicRoughness: GPUTextureView; + normal: GPUTextureView; + occlusion: GPUTextureView; + emissive: GPUTextureView; +} + +export interface FallbackViews { + white: GPUTextureView; + black: GPUTextureView; + flatNormal: GPUTextureView; +} + +function defaultMaterial(): PbrMaterial { + return { + baseColorFactor: [1, 1, 1, 1], + emissiveFactor: [0, 0, 0], + metallicFactor: 1, + roughnessFactor: 1, + normalScale: 1, + occlusionStrength: 1, + }; +} + +function gltfToPbrMaterial(m: GltfMaterial): PbrMaterial { + const pbr = m.pbrMetallicRoughness; + return { + baseColorFactor: pbr?.baseColorFactor ?? [1, 1, 1, 1], + emissiveFactor: m.emissiveFactor ?? [0, 0, 0], + metallicFactor: pbr?.metallicFactor ?? 1, + roughnessFactor: pbr?.roughnessFactor ?? 1, + normalScale: m.normalTexture?.scale ?? 1, + occlusionStrength: m.occlusionTexture?.strength ?? 1, + }; +} + +function viewFor( + textures: GPUTexture[], + gltf: GltfAsset, + textureIndex: number | undefined, + fallback: GPUTextureView, +): GPUTextureView { + if (textureIndex === undefined) return fallback; + const tex = gltf.textures?.[textureIndex]; + if (!tex || tex.source === undefined) return fallback; + const gpuTexture = textures[tex.source]; + if (!gpuTexture) return fallback; + return gpuTexture.createView(); +} + +/** + * Builds the per-primitive material bind group: a small uniform buffer holding + * the PBR factors plus the five sampled textures and a shared sampler. + * + * Missing textures fall back to neutral 1x1 textures so the shader always has + * a valid binding (white for baseColor/occlusion, black for emissive, flat + * (0.5, 0.5, 1) for the normal map). + */ +export function buildMaterialBindGroup( + device: GPUDevice, + gltf: GltfAsset, + sourceTextures: GPUTexture[], + fallback: FallbackViews, + sampler: GPUSampler, + layoutGpu: GPUBindGroupLayout, + materialIndex: number | undefined, +): GPUBindGroup { + const material = materialIndex !== undefined && gltf.materials?.[materialIndex] + ? gltfToPbrMaterial(gltf.materials[materialIndex]) + : defaultMaterial(); + const gltfMat = materialIndex !== undefined ? gltf.materials?.[materialIndex] : undefined; + + const structBuffer: TypedBuffer = createStructBuffer(pbrMaterialSchema, layout.size); + structBuffer.set(0, material); + + let uniformBuffer = device.createBuffer({ + size: layout.size, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + uniformBuffer = copyToGPUBuffer(structBuffer, device, uniformBuffer); + + const baseColorView = viewFor(sourceTextures, gltf, gltfMat?.pbrMetallicRoughness?.baseColorTexture?.index, fallback.white); + const mrView = viewFor(sourceTextures, gltf, gltfMat?.pbrMetallicRoughness?.metallicRoughnessTexture?.index, fallback.white); + const normalView = viewFor(sourceTextures, gltf, gltfMat?.normalTexture?.index, fallback.flatNormal); + const occlusionView = viewFor(sourceTextures, gltf, gltfMat?.occlusionTexture?.index, fallback.white); + const emissiveView = viewFor(sourceTextures, gltf, gltfMat?.emissiveTexture?.index, fallback.black); + + return device.createBindGroup({ + layout: layoutGpu, + entries: [ + { binding: 0, resource: { buffer: uniformBuffer } }, + { binding: 1, resource: baseColorView }, + { binding: 2, resource: mrView }, + { binding: 3, resource: normalView }, + { binding: 4, resource: occlusionView }, + { binding: 5, resource: emissiveView }, + { binding: 6, resource: sampler }, + ], + }); +} diff --git a/packages/data-graphics/src/pbr/gltf/compute-world-matrices.ts b/packages/data-graphics/src/pbr/gltf/compute-world-matrices.ts new file mode 100644 index 00000000..7d79d618 --- /dev/null +++ b/packages/data-graphics/src/pbr/gltf/compute-world-matrices.ts @@ -0,0 +1,41 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Mat4x4, Quat } from "@adobe/data/math"; +import type { GltfAsset, GltfNode } from "./gltf-types.js"; + +function localMatrix(node: GltfNode): Mat4x4 { + if (node.matrix && node.matrix.length === 16) { + return node.matrix as unknown as Mat4x4; + } + const t = node.translation ?? [0, 0, 0]; + const r = node.rotation ?? [0, 0, 0, 1]; + const s = node.scale ?? [1, 1, 1]; + const T = Mat4x4.translation(t[0], t[1], t[2]); + const R = Quat.toMat4(r as unknown as Quat); + const S = Mat4x4.scaling(s[0], s[1], s[2]); + return Mat4x4.multiply(Mat4x4.multiply(T, R), S); +} + +/** + * Walks the scene graph and returns a world matrix for every node index. + * Nodes not reachable from a scene root get the identity matrix. + */ +export function computeWorldMatrices(gltf: GltfAsset): Mat4x4[] { + const result: Mat4x4[] = new Array((gltf.nodes ?? []).length).fill(Mat4x4.identity); + + const visit = (nodeIdx: number, parent: Mat4x4): void => { + const node = gltf.nodes![nodeIdx]; + const world = Mat4x4.multiply(parent, localMatrix(node)); + result[nodeIdx] = world; + for (const child of node.children ?? []) { + visit(child, world); + } + }; + + const sceneIdx = gltf.scene ?? 0; + const roots = gltf.scenes?.[sceneIdx]?.nodes ?? []; + for (const r of roots) { + visit(r, Mat4x4.identity); + } + return result; +} diff --git a/packages/data-graphics/src/pbr/gltf/decode-images.ts b/packages/data-graphics/src/pbr/gltf/decode-images.ts new file mode 100644 index 00000000..7ae99a5b --- /dev/null +++ b/packages/data-graphics/src/pbr/gltf/decode-images.ts @@ -0,0 +1,93 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { readImageBytes } from "./accessor-view.js"; +import type { GltfAsset } from "./gltf-types.js"; + +/** + * Categorizes each image index as either "color" (sRGB) or "data" (linear). + * glTF spec: baseColor and emissive textures are in sRGB color space; normal, + * metallic-roughness, and occlusion textures are in linear color space. + * + * When an image is referenced from both kinds of slot, "color" wins because + * losing precision in a data texture is more visible than gamma drift in a + * color one. + */ +function categorizeImages(gltf: GltfAsset): ("color" | "data")[] { + const out: ("color" | "data")[] = new Array((gltf.images ?? []).length).fill("data"); + + const markColor = (textureIndex: number | undefined): void => { + if (textureIndex === undefined) return; + const t = gltf.textures?.[textureIndex]; + if (t?.source !== undefined) out[t.source] = "color"; + }; + + for (const mat of gltf.materials ?? []) { + markColor(mat.pbrMetallicRoughness?.baseColorTexture?.index); + markColor(mat.emissiveTexture?.index); + } + + return out; +} + +async function decodeOne( + device: GPUDevice, + bytes: Uint8Array, + mimeType: string | undefined, + colorSpace: "color" | "data", +): Promise { + const blob = new Blob([bytes], { type: mimeType ?? "image/png" }); + const bitmap = await createImageBitmap(blob, { colorSpaceConversion: "none" }); + const format: GPUTextureFormat = colorSpace === "color" ? "rgba8unorm-srgb" : "rgba8unorm"; + const texture = device.createTexture({ + size: [bitmap.width, bitmap.height, 1], + format, + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, + }); + device.queue.copyExternalImageToTexture( + { source: bitmap }, + { texture }, + [bitmap.width, bitmap.height, 1], + ); + bitmap.close(); + return texture; +} + +export async function decodeAllImages( + device: GPUDevice, + gltf: GltfAsset, + bin: ArrayBuffer, +): Promise { + const kinds = categorizeImages(gltf); + return Promise.all( + (gltf.images ?? []).map((image, idx) => { + const bytes = readImageBytes(gltf, bin, idx); + return decodeOne(device, bytes, image.mimeType, kinds[idx]); + }), + ); +} + +/** + * Creates 1x1 fallback textures that the renderer can bind when a material + * doesn't supply a particular slot. Avoids "if texture exists" branches in + * the shader. + */ +export function createFallbackTextures(device: GPUDevice): { + white: GPUTextureView; + black: GPUTextureView; + flatNormal: GPUTextureView; +} { + const make = (pixel: Uint8Array, format: GPUTextureFormat): GPUTextureView => { + const tex = device.createTexture({ + size: [1, 1, 1], + format, + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + device.queue.writeTexture({ texture: tex }, pixel, { bytesPerRow: 4 }, [1, 1, 1]); + return tex.createView(); + }; + return { + white: make(new Uint8Array([255, 255, 255, 255]), "rgba8unorm-srgb"), + black: make(new Uint8Array([0, 0, 0, 255]), "rgba8unorm-srgb"), + flatNormal: make(new Uint8Array([128, 128, 255, 255]), "rgba8unorm"), + }; +} diff --git a/packages/data-graphics/src/pbr/gltf/gltf-types.ts b/packages/data-graphics/src/pbr/gltf/gltf-types.ts new file mode 100644 index 00000000..7342389f --- /dev/null +++ b/packages/data-graphics/src/pbr/gltf/gltf-types.ts @@ -0,0 +1,132 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Minimal subset of glTF 2.0 schema fields we actually consume. +// Spec: https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html + +export interface GltfAsset { + asset: { version: string }; + scene?: number; + scenes?: GltfScene[]; + nodes?: GltfNode[]; + meshes?: GltfMesh[]; + materials?: GltfMaterial[]; + textures?: GltfTexture[]; + images?: GltfImage[]; + samplers?: GltfSampler[]; + accessors?: GltfAccessor[]; + bufferViews?: GltfBufferView[]; + buffers?: GltfBuffer[]; +} + +export interface GltfScene { + nodes?: number[]; +} + +export interface GltfNode { + name?: string; + mesh?: number; + children?: number[]; + translation?: [number, number, number]; + rotation?: [number, number, number, number]; + scale?: [number, number, number]; + matrix?: number[]; +} + +export interface GltfMesh { + name?: string; + primitives: GltfPrimitive[]; +} + +export interface GltfPrimitive { + attributes: { + POSITION: number; + NORMAL?: number; + TANGENT?: number; + TEXCOORD_0?: number; + [key: string]: number | undefined; + }; + indices?: number; + material?: number; + mode?: number; // default 4 (triangles) +} + +export interface GltfMaterial { + name?: string; + pbrMetallicRoughness?: { + baseColorFactor?: [number, number, number, number]; + baseColorTexture?: { index: number; texCoord?: number }; + metallicFactor?: number; + roughnessFactor?: number; + metallicRoughnessTexture?: { index: number; texCoord?: number }; + }; + normalTexture?: { index: number; texCoord?: number; scale?: number }; + occlusionTexture?: { index: number; texCoord?: number; strength?: number }; + emissiveTexture?: { index: number; texCoord?: number }; + emissiveFactor?: [number, number, number]; + alphaMode?: "OPAQUE" | "MASK" | "BLEND"; + alphaCutoff?: number; + doubleSided?: boolean; +} + +export interface GltfTexture { + sampler?: number; + source?: number; +} + +export interface GltfImage { + name?: string; + mimeType?: string; + bufferView?: number; + uri?: string; +} + +export interface GltfSampler { + magFilter?: number; + minFilter?: number; + wrapS?: number; + wrapT?: number; +} + +export interface GltfAccessor { + bufferView?: number; + byteOffset?: number; + componentType: number; // 5120..5126 + count: number; + type: "SCALAR" | "VEC2" | "VEC3" | "VEC4" | "MAT2" | "MAT3" | "MAT4"; + normalized?: boolean; + min?: number[]; + max?: number[]; +} + +export interface GltfBufferView { + buffer: number; + byteOffset?: number; + byteLength: number; + byteStride?: number; + target?: number; +} + +export interface GltfBuffer { + byteLength: number; + uri?: string; +} + +// glTF componentType constants +export const COMPONENT_TYPE = { + BYTE: 5120, + UNSIGNED_BYTE: 5121, + SHORT: 5122, + UNSIGNED_SHORT: 5123, + UNSIGNED_INT: 5125, + FLOAT: 5126, +} as const; + +export const ACCESSOR_TYPE_COMPONENTS: Record = { + SCALAR: 1, + VEC2: 2, + VEC3: 3, + VEC4: 4, + MAT2: 4, + MAT3: 9, + MAT4: 16, +}; diff --git a/packages/data-graphics/src/pbr/gltf/load-gltf-model.ts b/packages/data-graphics/src/pbr/gltf/load-gltf-model.ts new file mode 100644 index 00000000..76614ef2 --- /dev/null +++ b/packages/data-graphics/src/pbr/gltf/load-gltf-model.ts @@ -0,0 +1,132 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; +import type { Vec3 } from "@adobe/data/math"; +import { graphics } from "../../plugins/graphics.js"; +import { createMaterialBindGroupLayout } from "../bind-group-layouts.js"; +import { pbrCore, type PbrPrimitiveInsert } from "../plugins/pbr-core.js"; +import { readAccessor } from "./accessor-view.js"; +import { buildMaterialBindGroup, type FallbackViews } from "./build-material-bind-group.js"; +import { computeWorldMatrices } from "./compute-world-matrices.js"; +import { createFallbackTextures, decodeAllImages } from "./decode-images.js"; +import { packPrimitiveVertices } from "./pack-vertex-buffer.js"; +import { parseGlb } from "./parse-glb.js"; + +export interface LoadedGltfModel { + boundsMin: Vec3; + boundsMax: Vec3; + primitiveCount: number; +} + +const loaderPlugin = Database.Plugin.combine(pbrCore, graphics); +type PbrDatabase = Database.Plugin.ToDatabase; + +function waitForDevice(db: PbrDatabase): Promise { + return new Promise(resolve => { + const unobserve = db.observe.resources.device(value => { + if (value) { + resolve(value); + queueMicrotask(() => unobserve?.()); + } + }); + }); +} + +function pickIndexFormat(raw: Uint16Array | Uint32Array | Uint8Array | Float32Array): { + indices: Uint16Array | Uint32Array; + format: GPUIndexFormat; +} { + if (raw instanceof Uint32Array) return { indices: raw, format: "uint32" }; + if (raw instanceof Uint16Array) return { indices: raw, format: "uint16" }; + if (raw instanceof Uint8Array) { + const u16 = new Uint16Array(raw.length); + for (let i = 0; i < raw.length; i++) u16[i] = raw[i]; + return { indices: u16, format: "uint16" }; + } + throw new Error("Index accessor must be an integer type"); +} + +/** + * Fetches and parses a GLB file at `url`, decodes its textures, builds GPU + * buffers and per-material bind groups, and inserts one PbrPrimitive entity + * per mesh primitive. Returns the model's world-space AABB so the caller can + * frame the camera. + */ +export async function loadGltfModel(db: PbrDatabase, url: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`); + const buffer = await response.arrayBuffer(); + + const device = await waitForDevice(db); + const { json, bin } = parseGlb(buffer); + + const materialLayout = createMaterialBindGroupLayout(device); + const sourceTextures = await decodeAllImages(device, json, bin); + const fallback: FallbackViews = createFallbackTextures(device); + + const sampler = device.createSampler({ + magFilter: "linear", + minFilter: "linear", + addressModeU: "repeat", + addressModeV: "repeat", + }); + + const worldMatrices = computeWorldMatrices(json); + + const modelMin: [number, number, number] = [Infinity, Infinity, Infinity]; + const modelMax: [number, number, number] = [-Infinity, -Infinity, -Infinity]; + + const primitives: PbrPrimitiveInsert[] = []; + + for (let nodeIdx = 0; nodeIdx < (json.nodes ?? []).length; nodeIdx++) { + const node = json.nodes![nodeIdx]; + if (node.mesh === undefined) continue; + const mesh = json.meshes![node.mesh]; + const worldMatrix = worldMatrices[nodeIdx]; + + for (const prim of mesh.primitives) { + const packed = packPrimitiveVertices(json, bin, prim, worldMatrix); + + for (let i = 0; i < 3; i++) { + if (packed.boundsMin[i] < modelMin[i]) modelMin[i] = packed.boundsMin[i]; + if (packed.boundsMax[i] > modelMax[i]) modelMax[i] = packed.boundsMax[i]; + } + + const vertexBuffer = device.createBuffer({ + size: packed.vertices.byteLength, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer(vertexBuffer, 0, packed.vertices); + + if (prim.indices === undefined) throw new Error("Non-indexed primitives not supported"); + const raw = readAccessor(json, bin, prim.indices); + const { indices, format } = pickIndexFormat(raw); + + const indexBuffer = device.createBuffer({ + size: indices.byteLength, + usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer(indexBuffer, 0, indices); + + const materialBindGroup = buildMaterialBindGroup( + device, json, sourceTextures, fallback, sampler, materialLayout, prim.material, + ); + + primitives.push({ + pbrVertexBuffer: vertexBuffer, + pbrIndexBuffer: indexBuffer, + pbrIndexCount: indices.length, + pbrIndexFormat: format, + pbrMaterialBindGroup: materialBindGroup, + }); + } + } + + db.transactions.pbrInsertPrimitives(primitives); + + return { + boundsMin: modelMin as unknown as Vec3, + boundsMax: modelMax as unknown as Vec3, + primitiveCount: primitives.length, + }; +} diff --git a/packages/data-graphics/src/pbr/gltf/pack-vertex-buffer.ts b/packages/data-graphics/src/pbr/gltf/pack-vertex-buffer.ts new file mode 100644 index 00000000..2fe933f6 --- /dev/null +++ b/packages/data-graphics/src/pbr/gltf/pack-vertex-buffer.ts @@ -0,0 +1,86 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import type { Mat4x4, Vec3 } from "@adobe/data/math"; +import { stride as vertexStride } from "../types/standard-vertex/layout.js"; +import { readAccessor } from "./accessor-view.js"; +import type { GltfAsset, GltfPrimitive } from "./gltf-types.js"; + +const FLOATS_PER_VERTEX = vertexStride / 4; // 48 / 4 = 12 + +type MutVec3 = [number, number, number]; + +function transformPoint(m: Mat4x4, x: number, y: number, z: number): MutVec3 { + const r0 = m[0] * x + m[4] * y + m[8] * z + m[12]; + const r1 = m[1] * x + m[5] * y + m[9] * z + m[13]; + const r2 = m[2] * x + m[6] * y + m[10] * z + m[14]; + return [r0, r1, r2]; +} + +function transformDirection(m: Mat4x4, x: number, y: number, z: number): MutVec3 { + const r0 = m[0] * x + m[4] * y + m[8] * z; + const r1 = m[1] * x + m[5] * y + m[9] * z; + const r2 = m[2] * x + m[6] * y + m[10] * z; + const len = Math.hypot(r0, r1, r2) || 1; + return [r0 / len, r1 / len, r2 / len]; +} + +export interface PackedPrimitive { + vertices: Float32Array; + vertexCount: number; + boundsMin: Vec3; + boundsMax: Vec3; +} + +/** + * Reads POSITION/NORMAL/TANGENT/TEXCOORD_0 from a primitive, applies the + * supplied world matrix on the CPU, and interleaves into the packed + * StandardVertex layout (48 bytes / vertex). + * + * Falls back to (1,0,0,1) tangent when the source primitive omits it — normal + * mapping will be incorrect on such meshes, but the renderer will not crash. + */ +export function packPrimitiveVertices( + gltf: GltfAsset, + bin: ArrayBuffer, + prim: GltfPrimitive, + worldMatrix: Mat4x4, +): PackedPrimitive { + const positions = readAccessor(gltf, bin, prim.attributes.POSITION) as Float32Array; + if (prim.attributes.NORMAL === undefined) throw new Error("Primitive missing NORMAL"); + if (prim.attributes.TEXCOORD_0 === undefined) throw new Error("Primitive missing TEXCOORD_0"); + + const normals = readAccessor(gltf, bin, prim.attributes.NORMAL) as Float32Array; + const uvs = readAccessor(gltf, bin, prim.attributes.TEXCOORD_0) as Float32Array; + const tangents = prim.attributes.TANGENT !== undefined + ? readAccessor(gltf, bin, prim.attributes.TANGENT) as Float32Array + : null; + + const vertexCount = positions.length / 3; + const out = new Float32Array(vertexCount * FLOATS_PER_VERTEX); + + const min: MutVec3 = [Infinity, Infinity, Infinity]; + const max: MutVec3 = [-Infinity, -Infinity, -Infinity]; + + for (let i = 0; i < vertexCount; i++) { + const wp = transformPoint(worldMatrix, positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); + const wn = transformDirection(worldMatrix, normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]); + + let tx = 1, ty = 0, tz = 0, tw = 1; + if (tangents) { + const wt = transformDirection(worldMatrix, tangents[i * 4], tangents[i * 4 + 1], tangents[i * 4 + 2]); + tx = wt[0]; ty = wt[1]; tz = wt[2]; tw = tangents[i * 4 + 3]; + } + + const o = i * FLOATS_PER_VERTEX; + out[o + 0] = wp[0]; out[o + 1] = wp[1]; out[o + 2] = wp[2]; + out[o + 3] = wn[0]; out[o + 4] = wn[1]; out[o + 5] = wn[2]; + out[o + 6] = tx; out[o + 7] = ty; out[o + 8] = tz; out[o + 9] = tw; + out[o + 10] = uvs[i * 2]; out[o + 11] = uvs[i * 2 + 1]; + + if (wp[0] < min[0]) min[0] = wp[0]; if (wp[0] > max[0]) max[0] = wp[0]; + if (wp[1] < min[1]) min[1] = wp[1]; if (wp[1] > max[1]) max[1] = wp[1]; + if (wp[2] < min[2]) min[2] = wp[2]; if (wp[2] > max[2]) max[2] = wp[2]; + } + + return { vertices: out, vertexCount, boundsMin: min as Vec3, boundsMax: max as Vec3 }; +} diff --git a/packages/data-graphics/src/pbr/gltf/parse-glb.ts b/packages/data-graphics/src/pbr/gltf/parse-glb.ts new file mode 100644 index 00000000..7ee77840 --- /dev/null +++ b/packages/data-graphics/src/pbr/gltf/parse-glb.ts @@ -0,0 +1,51 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import type { GltfAsset } from "./gltf-types.js"; + +const MAGIC_GLTF = 0x46546c67; // "glTF" +const CHUNK_JSON = 0x4e4f534a; // "JSON" +const CHUNK_BIN = 0x004e4942; // "BIN\0" + +export interface ParsedGlb { + json: GltfAsset; + bin: ArrayBuffer; +} + +export function parseGlb(buffer: ArrayBuffer): ParsedGlb { + if (buffer.byteLength < 12) { + throw new Error("GLB too short to contain header"); + } + const view = new DataView(buffer); + const magic = view.getUint32(0, true); + if (magic !== MAGIC_GLTF) { + throw new Error(`Not a GLB file (magic = 0x${magic.toString(16)})`); + } + const version = view.getUint32(4, true); + if (version !== 2) { + throw new Error(`Unsupported GLB version ${version}`); + } + + let offset = 12; + let json: GltfAsset | null = null; + let bin: ArrayBuffer | null = null; + + while (offset < buffer.byteLength) { + const chunkLength = view.getUint32(offset, true); + const chunkType = view.getUint32(offset + 4, true); + const chunkStart = offset + 8; + const chunkEnd = chunkStart + chunkLength; + + if (chunkType === CHUNK_JSON) { + const bytes = new Uint8Array(buffer, chunkStart, chunkLength); + const text = new TextDecoder().decode(bytes); + json = JSON.parse(text) as GltfAsset; + } else if (chunkType === CHUNK_BIN) { + bin = buffer.slice(chunkStart, chunkEnd); + } + + offset = chunkEnd; + } + + if (!json) throw new Error("GLB missing JSON chunk"); + return { json, bin: bin ?? new ArrayBuffer(0) }; +} diff --git a/packages/data-graphics/src/pbr/ibl/brdf-lut.ts b/packages/data-graphics/src/pbr/ibl/brdf-lut.ts new file mode 100644 index 00000000..0392a2ce --- /dev/null +++ b/packages/data-graphics/src/pbr/ibl/brdf-lut.ts @@ -0,0 +1,101 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import iblMath from "./ibl-math.wgsl.js"; +import { FULLSCREEN_VS } from "./render-helpers.js"; + +// Karis split-sum BRDF integration. Encodes the F0-independent part of the +// specular IBL integral. Lookup is (NdotV, roughness) → (scale, bias) such +// that specular_IBL = prefiltered * (F0 * scale + bias). +const BRDF_LUT_FS = /* wgsl */ ` +struct Params { size: u32, _pad: u32, _pad2: u32, _pad3: u32 }; +@group(0) @binding(0) var params: Params; + +const SAMPLES: u32 = 1024u; + +fn integrate(nDotV: f32, roughness: f32) -> vec2f { + let V = vec3f(sqrt(1.0 - nDotV * nDotV), 0.0, nDotV); + // N = (0,0,1): work directly in tangent space. importance_sample_ggx's + // TBN frame degenerates when N=(0,0,1), so we sample H inline here. + let a = roughness * roughness; + + var A = 0.0; + var B = 0.0; + for (var i = 0u; i < SAMPLES; i = i + 1u) { + let xi = hammersley(i, SAMPLES); + let phi = 2.0 * PI * xi.x; + let cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y)); + let sinTheta = sqrt(1.0 - cosTheta * cosTheta); + let H = vec3f(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); + let L = normalize(2.0 * dot(V, H) * H - V); + let nDotL = max(L.z, 0.0); + let nDotH = max(H.z, 0.0); + let vDotH = max(dot(V, H), 0.0); + if (nDotL > 0.0) { + let G = g_smith_ibl(nDotV, nDotL, roughness); + let g_vis = (G * vDotH) / max(nDotH * nDotV, 0.0001); + let fc = pow(1.0 - vDotH, 5.0); + A = A + (1.0 - fc) * g_vis; + B = B + fc * g_vis; + } + } + return vec2f(A, B) / f32(SAMPLES); +} + +@fragment +fn fs_main(@builtin(position) frag: vec4f) -> @location(0) vec4f { + let uv = frag.xy / f32(params.size); + let nDotV = max(uv.x, 0.001); + let roughness = max(uv.y, 0.04); + let r = integrate(nDotV, roughness); + return vec4f(r, 0.0, 1.0); +} +`; + +export function generateBrdfLut( + device: GPUDevice, + encoder: GPUCommandEncoder, + size = 256, +): GPUTexture { + const format: GPUTextureFormat = "rgba16float"; + const texture = device.createTexture({ + size: [size, size, 1], + format, + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT, + }); + + const module = device.createShaderModule({ code: FULLSCREEN_VS + iblMath + BRDF_LUT_FS }); + const bindGroupLayout = device.createBindGroupLayout({ + entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }], + }); + const pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }), + vertex: { module, entryPoint: "vs_fullscreen" }, + fragment: { module, entryPoint: "fs_main", targets: [{ format }] }, + primitive: { topology: "triangle-list" }, + }); + + const ub = device.createBuffer({ + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer(ub, 0, new Uint32Array([size, 0, 0, 0])); + const bindGroup = device.createBindGroup({ + layout: bindGroupLayout, + entries: [{ binding: 0, resource: { buffer: ub } }], + }); + + const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: texture.createView(), + loadOp: "clear", + storeOp: "store", + clearValue: [0, 0, 0, 1], + }], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); + + return texture; +} diff --git a/packages/data-graphics/src/pbr/ibl/build-ibl-resources.ts b/packages/data-graphics/src/pbr/ibl/build-ibl-resources.ts new file mode 100644 index 00000000..2a1728d8 --- /dev/null +++ b/packages/data-graphics/src/pbr/ibl/build-ibl-resources.ts @@ -0,0 +1,57 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { generateBrdfLut } from "./brdf-lut.js"; +import { generateEnvironment } from "./environment.js"; +import { generateIrradiance } from "./irradiance.js"; +import type { ParsedHdr } from "./parse-hdr.js"; +import { generatePrefiltered } from "./prefilter.js"; + +export interface IblResources { + environment: GPUTexture; + irradiance: GPUTexture; + prefiltered: GPUTexture; + prefilteredMipCount: number; + brdfLut: GPUTexture; +} + +export interface IblOptions { + environmentSize?: number; + irradianceSize?: number; + prefilteredSize?: number; + prefilteredMipCount?: number; + brdfLutSize?: number; + /** Equirectangular HDR source. When omitted, a procedural studio is used. */ + hdrSource?: ParsedHdr; +} + +/** + * Computes the four IBL maps used by the prefiltered split-sum approximation: + * source environment, diffuse irradiance, specular prefiltered (mip chain by + * roughness), and the BRDF integration LUT. Everything runs in one command + * buffer; the GPU sequences the dependencies internally. + */ +export function buildIblResources(device: GPUDevice, options: IblOptions = {}): IblResources { + const envSize = options.environmentSize ?? 512; + const irradianceSize = options.irradianceSize ?? 32; + const prefilteredSize = options.prefilteredSize ?? 256; + const prefilteredMipCount = options.prefilteredMipCount ?? 7; + const brdfLutSize = options.brdfLutSize ?? 256; + + const encoder = device.createCommandEncoder({ label: "ibl-precompute" }); + const environment = generateEnvironment(device, encoder, envSize, options.hdrSource); + const irradiance = generateIrradiance(device, encoder, environment, irradianceSize); + const prefiltered = generatePrefiltered(device, encoder, environment, prefilteredSize, prefilteredMipCount); + device.queue.submit([encoder.finish()]); + + const brdfEncoder = device.createCommandEncoder({ label: "ibl-brdf-lut" }); + const brdfLut = generateBrdfLut(device, brdfEncoder, brdfLutSize); + device.queue.submit([brdfEncoder.finish()]); + + return { + environment, + irradiance, + prefiltered: prefiltered.texture, + prefilteredMipCount: prefiltered.mipLevelCount, + brdfLut, + }; +} diff --git a/packages/data-graphics/src/pbr/ibl/environment.ts b/packages/data-graphics/src/pbr/ibl/environment.ts new file mode 100644 index 00000000..37cb3151 --- /dev/null +++ b/packages/data-graphics/src/pbr/ibl/environment.ts @@ -0,0 +1,190 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { floatArrayToHalf } from "./float-to-half.js"; +import iblMath from "./ibl-math.wgsl.js"; +import type { ParsedHdr } from "./parse-hdr.js"; +import { createCubemap, cubeFaceView, FULLSCREEN_VS } from "./render-helpers.js"; + +const ENV_PROCEDURAL_FS = /* wgsl */ ` +struct Params { face: u32, size: u32 }; +@group(0) @binding(0) var params: Params; + +fn env_color(dir: vec3f) -> vec3f { + let sky_top = vec3f(0.45, 0.55, 0.75); + let horizon = vec3f(0.18, 0.20, 0.24); + let ground = vec3f(0.08, 0.08, 0.10); + var color = mix(horizon, sky_top, smoothstep(-0.05, 0.65, dir.y)); + color = mix(ground, color, smoothstep(-0.3, 0.0, dir.y)); + + let warm = vec3f(9.0, 7.2, 5.0); + let cool = vec3f(5.0, 7.5, 10.0); + for (var i = 0u; i < 4u; i = i + 1u) { + let a = f32(i) * 1.5707963; + let ld = normalize(vec3f(sin(a) * 0.55, 0.85, cos(a) * 0.55)); + let d = max(dot(dir, ld), 0.0); + let intensity = pow(d, 96.0); + let lc = select(warm, cool, i % 2u == 0u); + color = color + intensity * lc; + } + return color; +} + +@fragment +fn fs_main(@builtin(position) frag: vec4f) -> @location(0) vec4f { + let uv = frag.xy / f32(params.size); + let dir = cube_uv_to_dir(params.face, uv); + return vec4f(env_color(dir), 1.0); +} +`; + +const ENV_EQUIRECT_FS = /* wgsl */ ` +struct Params { face: u32, size: u32 }; +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var equirect: texture_2d; +@group(0) @binding(2) var equirectSampler: sampler; + +fn dir_to_equirect_uv(d: vec3f) -> vec2f { + let theta = atan2(d.z, d.x); + let phi = asin(clamp(d.y, -1.0, 1.0)); + return vec2f((theta + PI) / (2.0 * PI), 0.5 - phi / PI); +} + +@fragment +fn fs_main(@builtin(position) frag: vec4f) -> @location(0) vec4f { + let uv = frag.xy / f32(params.size); + let dir = cube_uv_to_dir(params.face, uv); + let env_uv = dir_to_equirect_uv(dir); + return textureSampleLevel(equirect, equirectSampler, env_uv, 0.0); +} +`; + +function uploadHdrAsTexture(device: GPUDevice, hdr: ParsedHdr): GPUTexture { + const halfData = floatArrayToHalf(hdr.rgba); + const texture = device.createTexture({ + size: [hdr.width, hdr.height, 1], + format: "rgba16float", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + device.queue.writeTexture( + { texture }, + halfData.buffer, + { bytesPerRow: hdr.width * 8 }, + [hdr.width, hdr.height, 1], + ); + return texture; +} + +function makeProceduralPipeline(device: GPUDevice, format: GPUTextureFormat) { + const module = device.createShaderModule({ code: FULLSCREEN_VS + iblMath + ENV_PROCEDURAL_FS }); + const bindGroupLayout = device.createBindGroupLayout({ + entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }], + }); + const pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }), + vertex: { module, entryPoint: "vs_fullscreen" }, + fragment: { module, entryPoint: "fs_main", targets: [{ format }] }, + primitive: { topology: "triangle-list" }, + }); + return { pipeline, bindGroupLayout }; +} + +function makeEquirectPipeline(device: GPUDevice, format: GPUTextureFormat) { + const module = device.createShaderModule({ code: FULLSCREEN_VS + iblMath + ENV_EQUIRECT_FS }); + const bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d" } }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }, + ], + }); + const pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }), + vertex: { module, entryPoint: "vs_fullscreen" }, + fragment: { module, entryPoint: "fs_main", targets: [{ format }] }, + primitive: { topology: "triangle-list" }, + }); + return { pipeline, bindGroupLayout }; +} + +/** + * Renders an environment cubemap. With no `hdr` source it falls back to the + * built-in procedural studio. Pass a parsed Radiance HDR for a real photoreal + * environment — used by the IBL plugin to enable Babylon-style glossy + * reflections. + */ +export function generateEnvironment( + device: GPUDevice, + encoder: GPUCommandEncoder, + size: number, + hdr?: ParsedHdr, +): GPUTexture { + const format: GPUTextureFormat = "rgba16float"; + const texture = createCubemap(device, size, format, 1); + + if (hdr) { + const equirectTexture = uploadHdrAsTexture(device, hdr); + const sampler = device.createSampler({ + magFilter: "linear", + minFilter: "linear", + addressModeU: "repeat", + addressModeV: "clamp-to-edge", + }); + const { pipeline, bindGroupLayout } = makeEquirectPipeline(device, format); + const equirectView = equirectTexture.createView(); + + for (let face = 0; face < 6; face++) { + const ub = device.createBuffer({ + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer(ub, 0, new Uint32Array([face, size, 0, 0])); + const bindGroup = device.createBindGroup({ + layout: bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: ub } }, + { binding: 1, resource: equirectView }, + { binding: 2, resource: sampler }, + ], + }); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: cubeFaceView(texture, face, 0), + loadOp: "clear", + storeOp: "store", + clearValue: [0, 0, 0, 1], + }], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); + } + return texture; + } + + const { pipeline, bindGroupLayout } = makeProceduralPipeline(device, format); + for (let face = 0; face < 6; face++) { + const ub = device.createBuffer({ + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer(ub, 0, new Uint32Array([face, size, 0, 0])); + const bindGroup = device.createBindGroup({ + layout: bindGroupLayout, + entries: [{ binding: 0, resource: { buffer: ub } }], + }); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: cubeFaceView(texture, face, 0), + loadOp: "clear", + storeOp: "store", + clearValue: [0, 0, 0, 1], + }], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); + } + return texture; +} diff --git a/packages/data-graphics/src/pbr/ibl/float-to-half.ts b/packages/data-graphics/src/pbr/ibl/float-to-half.ts new file mode 100644 index 00000000..63b23333 --- /dev/null +++ b/packages/data-graphics/src/pbr/ibl/float-to-half.ts @@ -0,0 +1,35 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// IEEE 754 binary16 (half-float) encoder. WebGPU's rgba16float upload path +// expects Uint16-packed half-floats; Float32 source data has to be converted +// element-wise. Standard bit-twiddle, no infinity/NaN edge cases needed for +// well-formed HDRI input (subnormals are flushed to zero). + +const f32 = new Float32Array(1); +const u32 = new Uint32Array(f32.buffer); + +export function floatToHalf(value: number): number { + f32[0] = value; + const bits = u32[0]; + + const sign = (bits >> 16) & 0x8000; + const expRaw = (bits >> 23) & 0xff; + const mantissa = bits & 0x7fffff; + + if (expRaw === 0xff) { + // Infinity or NaN. + return sign | 0x7c00 | (mantissa ? 0x200 : 0); + } + const exp = expRaw - 127; + if (exp >= 16) return sign | 0x7c00; // overflow → ±Inf + if (exp < -14) return sign; // underflow → ±0 + return sign | ((exp + 15) << 10) | (mantissa >> 13); +} + +export function floatArrayToHalf(src: Float32Array): Uint16Array { + const out = new Uint16Array(src.length); + for (let i = 0; i < src.length; i++) { + out[i] = floatToHalf(src[i]); + } + return out; +} diff --git a/packages/data-graphics/src/pbr/ibl/ibl-math.wgsl.ts b/packages/data-graphics/src/pbr/ibl/ibl-math.wgsl.ts new file mode 100644 index 00000000..5ed352cc --- /dev/null +++ b/packages/data-graphics/src/pbr/ibl/ibl-math.wgsl.ts @@ -0,0 +1,60 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Shared WGSL math for IBL precomputation passes. +// - cube_uv_to_dir: WebGPU cube layer (face 0..5) + 2D uv → world-space direction +// - hammersley / radical_inverse_vdc: low-discrepancy 2D sample sequence +// - importance_sample_ggx: GGX-distributed half-vector in tangent space, returned in world space + +export default /* wgsl */ ` +const PI: f32 = 3.14159265359; + +fn cube_uv_to_dir(face: u32, uv: vec2f) -> vec3f { + let p = uv * 2.0 - vec2f(1.0); + var dir: vec3f; + switch face { + case 0u: { dir = vec3f( 1.0, -p.y, -p.x); } + case 1u: { dir = vec3f(-1.0, -p.y, p.x); } + case 2u: { dir = vec3f( p.x, 1.0, p.y); } + case 3u: { dir = vec3f( p.x, -1.0, -p.y); } + case 4u: { dir = vec3f( p.x, -p.y, 1.0); } + default: { dir = vec3f(-p.x, -p.y, -1.0); } + } + return normalize(dir); +} + +fn radical_inverse_vdc(bitsIn: u32) -> f32 { + var bits = bitsIn; + bits = (bits << 16u) | (bits >> 16u); + bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u); + bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u); + bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u); + bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u); + return f32(bits) * 2.3283064365386963e-10; +} + +fn hammersley(i: u32, n: u32) -> vec2f { + return vec2f(f32(i) / f32(n), radical_inverse_vdc(i)); +} + +fn importance_sample_ggx(xi: vec2f, N: vec3f, roughness: f32) -> vec3f { + let a = roughness * roughness; + let phi = 2.0 * PI * xi.x; + let cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y)); + let sinTheta = sqrt(1.0 - cosTheta * cosTheta); + let H_tangent = vec3f(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); + + let up = select(vec3f(0.0, 0.0, 1.0), vec3f(1.0, 0.0, 0.0), abs(N.z) < 0.999); + let tangent = normalize(cross(up, N)); + let bitangent = cross(N, tangent); + + return normalize(tangent * H_tangent.x + bitangent * H_tangent.y + N * H_tangent.z); +} + +fn g_smith_ibl(nDotV: f32, nDotL: f32, roughness: f32) -> f32 { + let a = roughness; + let k = (a * a) / 2.0; + let gv = nDotV / (nDotV * (1.0 - k) + k); + let gl = nDotL / (nDotL * (1.0 - k) + k); + return gv * gl; +} +`; diff --git a/packages/data-graphics/src/pbr/ibl/irradiance.ts b/packages/data-graphics/src/pbr/ibl/irradiance.ts new file mode 100644 index 00000000..7ae4b25e --- /dev/null +++ b/packages/data-graphics/src/pbr/ibl/irradiance.ts @@ -0,0 +1,105 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import iblMath from "./ibl-math.wgsl.js"; +import { createCubemap, cubeFaceView, cubemapSampleView, FULLSCREEN_VS } from "./render-helpers.js"; + +// Cosine-weighted hemispherical Monte-Carlo convolution of the source +// environment. With cos-weighted PDF, irradiance/π = mean(L(ωi)) — no extra +// factor needed. 1024 samples is plenty for a 32x32 output. +const IRRADIANCE_FS = /* wgsl */ ` +struct Params { face: u32, size: u32 }; +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var envMap: texture_cube; +@group(0) @binding(2) var envSampler: sampler; + +const SAMPLES: u32 = 1024u; + +fn convolve(N: vec3f) -> vec3f { + let up = select(vec3f(0.0, 0.0, 1.0), vec3f(1.0, 0.0, 0.0), abs(N.z) < 0.999); + let tangent = normalize(cross(up, N)); + let bitangent = cross(N, tangent); + + var acc = vec3f(0.0); + for (var i = 0u; i < SAMPLES; i = i + 1u) { + let xi = hammersley(i, SAMPLES); + let phi = 2.0 * PI * xi.x; + let cosTheta = sqrt(xi.y); + let sinTheta = sqrt(1.0 - xi.y); + let local = vec3f(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); + let dir = normalize(tangent * local.x + bitangent * local.y + N * local.z); + acc = acc + textureSampleLevel(envMap, envSampler, dir, 0.0).rgb; + } + return acc / f32(SAMPLES); +} + +@fragment +fn fs_main(@builtin(position) frag: vec4f) -> @location(0) vec4f { + let uv = frag.xy / f32(params.size); + let N = cube_uv_to_dir(params.face, uv); + return vec4f(convolve(N), 1.0); +} +`; + +export function generateIrradiance( + device: GPUDevice, + encoder: GPUCommandEncoder, + envMap: GPUTexture, + size: number, +): GPUTexture { + const format: GPUTextureFormat = "rgba16float"; + const texture = createCubemap(device, size, format, 1); + + const module = device.createShaderModule({ code: FULLSCREEN_VS + iblMath + IRRADIANCE_FS }); + const bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "cube" } }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }, + ], + }); + const pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }), + vertex: { module, entryPoint: "vs_fullscreen" }, + fragment: { module, entryPoint: "fs_main", targets: [{ format }] }, + primitive: { topology: "triangle-list" }, + }); + + const sampler = device.createSampler({ + magFilter: "linear", + minFilter: "linear", + addressModeU: "clamp-to-edge", + addressModeV: "clamp-to-edge", + addressModeW: "clamp-to-edge", + }); + const envView = cubemapSampleView(envMap); + + for (let face = 0; face < 6; face++) { + const ub = device.createBuffer({ + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer(ub, 0, new Uint32Array([face, size, 0, 0])); + const bindGroup = device.createBindGroup({ + layout: bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: ub } }, + { binding: 1, resource: envView }, + { binding: 2, resource: sampler }, + ], + }); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: cubeFaceView(texture, face, 0), + loadOp: "clear", + storeOp: "store", + clearValue: [0, 0, 0, 1], + }], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); + } + + return texture; +} diff --git a/packages/data-graphics/src/pbr/ibl/parse-hdr.ts b/packages/data-graphics/src/pbr/ibl/parse-hdr.ts new file mode 100644 index 00000000..12d40359 --- /dev/null +++ b/packages/data-graphics/src/pbr/ibl/parse-hdr.ts @@ -0,0 +1,108 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Minimal Radiance .hdr / RGBE parser. Decodes the run-length encoded form +// (standard for files written by HDRshop / Photosphere / Polyhaven) and +// returns Float32 RGBA in row-major order. A=1 for every pixel. + +export interface ParsedHdr { + width: number; + height: number; + rgba: Float32Array; +} + +export function parseHdr(buffer: ArrayBuffer): ParsedHdr { + const bytes = new Uint8Array(buffer); + + // Header: ASCII, terminated by a blank line; then the resolution line. + const text = new TextDecoder("latin1").decode(bytes); + const headerEnd = text.indexOf("\n\n"); + if (headerEnd < 0) throw new Error("HDR: missing header terminator"); + const header = text.slice(0, headerEnd); + if (!header.startsWith("#?RADIANCE") && !header.startsWith("#?RGBE")) { + throw new Error("HDR: unknown magic"); + } + if (!/FORMAT=32-bit_rle_rgbe/i.test(header)) { + throw new Error("HDR: only 32-bit_rle_rgbe is supported"); + } + + // Resolution: e.g. "-Y 512 +X 1024". Negative Y means top-to-bottom. + const resStart = headerEnd + 2; + const newline = bytes.indexOf(0x0a, resStart); + const resLine = new TextDecoder().decode(bytes.subarray(resStart, newline)); + const m = /([+-][YX])\s+(\d+)\s+([+-][YX])\s+(\d+)/.exec(resLine); + if (!m) throw new Error(`HDR: bad resolution line "${resLine}"`); + const axisA = m[1], dimA = +m[2], axisB = m[3], dimB = +m[4]; + // We only handle the common -Y +X / +Y +X orientations. + let width: number, height: number, flipY: boolean; + if (axisA[1] === "Y") { + height = dimA; width = dimB; + flipY = axisA[0] === "+"; + } else { + width = dimA; height = dimB; + flipY = axisB[0] === "+"; + } + if (axisA[1] === axisB[1]) throw new Error("HDR: redundant axes"); + + const data = bytes.subarray(newline + 1); + let p = 0; + const rgba = new Float32Array(width * height * 4); + + const scanline = new Uint8Array(width * 4); + for (let y = 0; y < height; y++) { + if (p + 4 > data.length) throw new Error("HDR: truncated scanline header"); + // RLE-encoded scanline: starts with 0x02 0x02 (hi lo) of width. + if ( + data[p] === 0x02 && data[p + 1] === 0x02 && + ((data[p + 2] << 8) | data[p + 3]) === width && + width >= 8 && width <= 0x7fff + ) { + p += 4; + for (let c = 0; c < 4; c++) { + let x = 0; + while (x < width) { + if (p >= data.length) throw new Error("HDR: truncated RLE channel"); + const len = data[p++]; + if (len > 128) { + const runLen = len - 128; + if (p >= data.length || x + runLen > width) throw new Error("HDR: bad run"); + const val = data[p++]; + for (let i = 0; i < runLen; i++) scanline[(x + i) * 4 + c] = val; + x += runLen; + } else { + if (p + len > data.length || x + len > width) throw new Error("HDR: bad literal"); + for (let i = 0; i < len; i++) scanline[(x + i) * 4 + c] = data[p + i]; + p += len; + x += len; + } + } + } + } else { + // Old-style: raw RGBE pixels in raster order. + if (p + width * 4 > data.length) throw new Error("HDR: truncated raw scanline"); + scanline.set(data.subarray(p, p + width * 4)); + p += width * 4; + } + + const dstY = flipY ? height - 1 - y : y; + const rowOffset = dstY * width * 4; + for (let x = 0; x < width; x++) { + const r = scanline[x * 4]; + const g = scanline[x * 4 + 1]; + const b = scanline[x * 4 + 2]; + const e = scanline[x * 4 + 3]; + if (e === 0) { + rgba[rowOffset + x * 4 + 0] = 0; + rgba[rowOffset + x * 4 + 1] = 0; + rgba[rowOffset + x * 4 + 2] = 0; + } else { + const scale = Math.pow(2, e - 128) / 256; + rgba[rowOffset + x * 4 + 0] = r * scale; + rgba[rowOffset + x * 4 + 1] = g * scale; + rgba[rowOffset + x * 4 + 2] = b * scale; + } + rgba[rowOffset + x * 4 + 3] = 1; + } + } + + return { width, height, rgba }; +} diff --git a/packages/data-graphics/src/pbr/ibl/prefilter.ts b/packages/data-graphics/src/pbr/ibl/prefilter.ts new file mode 100644 index 00000000..c208271f --- /dev/null +++ b/packages/data-graphics/src/pbr/ibl/prefilter.ts @@ -0,0 +1,121 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import iblMath from "./ibl-math.wgsl.js"; +import { createCubemap, cubeFaceView, cubemapSampleView, FULLSCREEN_VS } from "./render-helpers.js"; + +// Split-sum prefiltered specular: each mip stores GGX importance-sampled +// reflections at a fixed roughness. Mip 0 = mirror (roughness 0), mip max = +// fully blurred (roughness 1). Uses Karis' simplification of N == V == R. +const PREFILTER_FS = /* wgsl */ ` +struct Params { face: u32, size: u32, roughness: f32, _pad: f32 }; +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var envMap: texture_cube; +@group(0) @binding(2) var envSampler: sampler; + +const SAMPLES: u32 = 1024u; + +fn prefilter(N: vec3f) -> vec3f { + if (params.roughness < 0.001) { + return textureSampleLevel(envMap, envSampler, N, 0.0).rgb; + } + let V = N; + var color = vec3f(0.0); + var weight = 0.0; + for (var i = 0u; i < SAMPLES; i = i + 1u) { + let xi = hammersley(i, SAMPLES); + let H = importance_sample_ggx(xi, N, params.roughness); + let L = normalize(2.0 * dot(V, H) * H - V); + let nDotL = max(dot(N, L), 0.0); + if (nDotL > 0.0) { + color = color + textureSampleLevel(envMap, envSampler, L, 0.0).rgb * nDotL; + weight = weight + nDotL; + } + } + return color / max(weight, 0.0001); +} + +@fragment +fn fs_main(@builtin(position) frag: vec4f) -> @location(0) vec4f { + let uv = frag.xy / f32(params.size); + let N = cube_uv_to_dir(params.face, uv); + return vec4f(prefilter(N), 1.0); +} +`; + +export interface PrefilteredResult { + texture: GPUTexture; + mipLevelCount: number; +} + +export function generatePrefiltered( + device: GPUDevice, + encoder: GPUCommandEncoder, + envMap: GPUTexture, + baseSize: number, + mipLevelCount: number, +): PrefilteredResult { + const format: GPUTextureFormat = "rgba16float"; + const texture = createCubemap(device, baseSize, format, mipLevelCount); + + const module = device.createShaderModule({ code: FULLSCREEN_VS + iblMath + PREFILTER_FS }); + const bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "cube" } }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }, + ], + }); + const pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }), + vertex: { module, entryPoint: "vs_fullscreen" }, + fragment: { module, entryPoint: "fs_main", targets: [{ format }] }, + primitive: { topology: "triangle-list" }, + }); + + const sampler = device.createSampler({ + magFilter: "linear", + minFilter: "linear", + addressModeU: "clamp-to-edge", + addressModeV: "clamp-to-edge", + addressModeW: "clamp-to-edge", + }); + const envView = cubemapSampleView(envMap); + + for (let mip = 0; mip < mipLevelCount; mip++) { + const mipSize = Math.max(1, baseSize >> mip); + const roughness = mipLevelCount > 1 ? mip / (mipLevelCount - 1) : 0; + for (let face = 0; face < 6; face++) { + const ub = device.createBuffer({ + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + const buf = new ArrayBuffer(16); + new Uint32Array(buf, 0, 2).set([face, mipSize]); + new Float32Array(buf, 8, 2).set([roughness, 0]); + device.queue.writeBuffer(ub, 0, buf); + + const bindGroup = device.createBindGroup({ + layout: bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: ub } }, + { binding: 1, resource: envView }, + { binding: 2, resource: sampler }, + ], + }); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: cubeFaceView(texture, face, mip), + loadOp: "clear", + storeOp: "store", + clearValue: [0, 0, 0, 1], + }], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); + } + } + + return { texture, mipLevelCount }; +} diff --git a/packages/data-graphics/src/pbr/ibl/render-helpers.ts b/packages/data-graphics/src/pbr/ibl/render-helpers.ts new file mode 100644 index 00000000..cca2db7e --- /dev/null +++ b/packages/data-graphics/src/pbr/ibl/render-helpers.ts @@ -0,0 +1,47 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Fullscreen-triangle vertex shader: three vertices generate a clip-space +// triangle that covers the viewport. The fragment shader then uses +// @builtin(position) (framebuffer pixels) to derive per-fragment UVs. +export const FULLSCREEN_VS = /* wgsl */ ` +@vertex +fn vs_fullscreen(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + let xs = array(-1.0, 3.0, -1.0); + let ys = array(-1.0, -1.0, 3.0); + return vec4f(xs[i], ys[i], 0.0, 1.0); +} +`; + +export function createCubemap( + device: GPUDevice, + size: number, + format: GPUTextureFormat, + mipLevelCount = 1, +): GPUTexture { + return device.createTexture({ + size: [size, size, 6], + format, + mipLevelCount, + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST, + dimension: "2d", + textureBindingViewDimension: "cube", + }); +} + +export function cubeFaceView(texture: GPUTexture, face: number, mip = 0): GPUTextureView { + return texture.createView({ + dimension: "2d", + baseArrayLayer: face, + arrayLayerCount: 1, + baseMipLevel: mip, + mipLevelCount: 1, + }); +} + +export function cubemapSampleView(texture: GPUTexture): GPUTextureView { + return texture.createView({ + dimension: "cube", + baseArrayLayer: 0, + arrayLayerCount: 6, + }); +} diff --git a/packages/data-graphics/src/pbr/index.ts b/packages/data-graphics/src/pbr/index.ts new file mode 100644 index 00000000..3956dadf --- /dev/null +++ b/packages/data-graphics/src/pbr/index.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export { pbrCore } from "./plugins/pbr-core.js"; +export type { PbrPrimitiveInsert } from "./plugins/pbr-core.js"; +export { pbrDirect } from "./plugins/pbr-direct.js"; +export { pbrIbl } from "./plugins/pbr-ibl.js"; +export { loadGltfModel } from "./gltf/load-gltf-model.js"; +export type { LoadedGltfModel } from "./gltf/load-gltf-model.js"; +export { StandardVertex } from "./types/standard-vertex/standard-vertex.js"; +export { PbrMaterial } from "./types/pbr-material/pbr-material.js"; diff --git a/packages/data-graphics/src/pbr/plugins/brdf.wgsl.ts b/packages/data-graphics/src/pbr/plugins/brdf.wgsl.ts new file mode 100644 index 00000000..b201f50c --- /dev/null +++ b/packages/data-graphics/src/pbr/plugins/brdf.wgsl.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Shared WGSL fragment: Cook-Torrance metallic-roughness BRDF building blocks. +// Concatenated into the direct and (future) IBL shader modules so both paths +// agree on the math. + +export default /* wgsl */ ` +const PI: f32 = 3.14159265359; +const MIN_ROUGHNESS: f32 = 0.04; + +// Trowbridge-Reitz / GGX normal distribution. +fn d_ggx(nDotH: f32, alpha: f32) -> f32 { + let a2 = alpha * alpha; + let denom = nDotH * nDotH * (a2 - 1.0) + 1.0; + return a2 / (PI * denom * denom); +} + +// Smith G with Schlick-GGX. +fn g_smith(nDotV: f32, nDotL: f32, alpha: f32) -> f32 { + let k = (alpha + 1.0) * (alpha + 1.0) / 8.0; + let gv = nDotV / (nDotV * (1.0 - k) + k); + let gl = nDotL / (nDotL * (1.0 - k) + k); + return gv * gl; +} + +// Schlick Fresnel. +fn f_schlick(vDotH: f32, f0: vec3f) -> vec3f { + let f = pow(clamp(1.0 - vDotH, 0.0, 1.0), 5.0); + return f0 + (vec3f(1.0) - f0) * f; +} + +// Roughness-aware Schlick for IBL: avoids over-strong rim at high roughness. +fn f_schlick_roughness(nDotV: f32, f0: vec3f, roughness: f32) -> vec3f { + let r = vec3f(1.0 - roughness); + return f0 + (max(r, f0) - f0) * pow(max(1.0 - nDotV, 0.0), 5.0); +} + +// Narkowicz ACES filmic tonemap fit. Cheap, looks clearly filmic. +fn tone_map_aces(x: vec3f) -> vec3f { + let a = 2.51; let b = 0.03; let c = 2.43; let d = 0.59; let e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3f(0.0), vec3f(1.0)); +} +`; diff --git a/packages/data-graphics/src/pbr/plugins/direct-shader.wgsl.ts b/packages/data-graphics/src/pbr/plugins/direct-shader.wgsl.ts new file mode 100644 index 00000000..77670a0e --- /dev/null +++ b/packages/data-graphics/src/pbr/plugins/direct-shader.wgsl.ts @@ -0,0 +1,111 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { wgslStructFields } from "@adobe/data/typed-buffer"; +import { schema as sceneUniformsSchema } from "../../types/scene-uniforms/schema.js"; +import { schema as pbrMaterialSchema } from "../types/pbr-material/schema.js"; +import brdf from "./brdf.wgsl.js"; + +export default /* wgsl */ ` +struct SceneUniforms { +${wgslStructFields(sceneUniformsSchema)} +} + +struct PbrMaterial { +${wgslStructFields(pbrMaterialSchema)} +} + +@group(0) @binding(0) var scene: SceneUniforms; + +@group(1) @binding(0) var material: PbrMaterial; +@group(1) @binding(1) var baseColorTexture: texture_2d; +@group(1) @binding(2) var metallicRoughnessTexture: texture_2d; +@group(1) @binding(3) var normalTexture: texture_2d; +@group(1) @binding(4) var occlusionTexture: texture_2d; +@group(1) @binding(5) var emissiveTexture: texture_2d; +@group(1) @binding(6) var pbrSampler: sampler; + +struct VertexInput { + @location(0) position: vec3f, + @location(1) normal: vec3f, + @location(2) tangent: vec4f, + @location(3) uv: vec2f, +} + +struct VertexOutput { + @builtin(position) clipPosition: vec4f, + @location(0) worldPosition: vec3f, + @location(1) normal: vec3f, + @location(2) tangent: vec3f, + @location(3) bitangent: vec3f, + @location(4) uv: vec2f, +} + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + let world = vec4f(in.position, 1.0); + out.clipPosition = scene.viewProjectionMatrix * world; + out.worldPosition = world.xyz; + out.normal = normalize(in.normal); + out.tangent = normalize(in.tangent.xyz); + out.bitangent = normalize(cross(out.normal, out.tangent) * in.tangent.w); + out.uv = in.uv; + return out; +} + +${brdf} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4f { + let baseColorSample = textureSample(baseColorTexture, pbrSampler, in.uv); + let baseColor = baseColorSample * material.baseColorFactor; + + let mrSample = textureSample(metallicRoughnessTexture, pbrSampler, in.uv); + let metallic = mrSample.b * material.metallicFactor; + var roughness = mrSample.g * material.roughnessFactor; + roughness = max(roughness, MIN_ROUGHNESS); + let alpha = roughness * roughness; + + let occlusion = textureSample(occlusionTexture, pbrSampler, in.uv).r; + let emissive = textureSample(emissiveTexture, pbrSampler, in.uv).rgb * material.emissiveFactor; + + let nSampled = textureSample(normalTexture, pbrSampler, in.uv).rgb * 2.0 - vec3f(1.0); + let nScaled = vec3f(nSampled.xy * material.normalScale, nSampled.z); + let tbn = mat3x3(in.tangent, in.bitangent, in.normal); + let N = normalize(tbn * nScaled); + + let V = normalize(scene.cameraPosition - in.worldPosition); + let L = normalize(-scene.lightDirection); + let H = normalize(V + L); + + let nDotL = max(dot(N, L), 0.0); + let nDotV = max(dot(N, V), 0.0001); + let nDotH = max(dot(N, H), 0.0); + let vDotH = max(dot(V, H), 0.0); + + let f0 = mix(vec3f(0.04), baseColor.rgb, metallic); + + let D = d_ggx(nDotH, alpha); + let G = g_smith(nDotV, nDotL, alpha); + let F = f_schlick(vDotH, f0); + + let specular = (D * G * F) / (4.0 * nDotV * nDotL + 0.0001); + + let kS = F; + let kD = (vec3f(1.0) - kS) * (1.0 - metallic); + let diffuse = kD * baseColor.rgb / PI; + + let direct = (diffuse + specular) * scene.lightColor * nDotL; + let ambient = baseColor.rgb * scene.lightColor * scene.ambientStrength * mix(1.0, occlusion, material.occlusionStrength); + + let color = direct + ambient + emissive; + + // Reinhard tone-map + gamma. Adequate for the direct-lighting path; the IBL + // plugin should swap this for ACES or a filmic curve once HDR reflections + // make brightness range much wider. + let mapped = color / (color + vec3f(1.0)); + let gamma = pow(mapped, vec3f(1.0 / 2.2)); + + return vec4f(gamma, baseColor.a); +} +`; diff --git a/packages/data-graphics/src/pbr/plugins/ibl-shader.wgsl.ts b/packages/data-graphics/src/pbr/plugins/ibl-shader.wgsl.ts new file mode 100644 index 00000000..be14d988 --- /dev/null +++ b/packages/data-graphics/src/pbr/plugins/ibl-shader.wgsl.ts @@ -0,0 +1,127 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { wgslStructFields } from "@adobe/data/typed-buffer"; +import { schema as sceneUniformsSchema } from "../../types/scene-uniforms/schema.js"; +import { schema as pbrMaterialSchema } from "../types/pbr-material/schema.js"; +import brdf from "./brdf.wgsl.js"; + +export interface IblShaderOptions { + prefilteredMipCount: number; +} + +export function buildIblShader(options: IblShaderOptions): string { + return /* wgsl */ ` +struct SceneUniforms { +${wgslStructFields(sceneUniformsSchema)} +} + +struct PbrMaterial { +${wgslStructFields(pbrMaterialSchema)} +} + +@group(0) @binding(0) var scene: SceneUniforms; + +@group(1) @binding(0) var material: PbrMaterial; +@group(1) @binding(1) var baseColorTexture: texture_2d; +@group(1) @binding(2) var metallicRoughnessTexture: texture_2d; +@group(1) @binding(3) var normalTexture: texture_2d; +@group(1) @binding(4) var occlusionTexture: texture_2d; +@group(1) @binding(5) var emissiveTexture: texture_2d; +@group(1) @binding(6) var pbrSampler: sampler; + +@group(2) @binding(0) var iblIrradiance: texture_cube; +@group(2) @binding(1) var iblPrefiltered: texture_cube; +@group(2) @binding(2) var iblBrdfLut: texture_2d; +@group(2) @binding(3) var iblSampler: sampler; + +const PREFILTERED_MIP_COUNT: f32 = ${options.prefilteredMipCount.toFixed(1)}; + +struct VertexInput { + @location(0) position: vec3f, + @location(1) normal: vec3f, + @location(2) tangent: vec4f, + @location(3) uv: vec2f, +} + +struct VertexOutput { + @builtin(position) clipPosition: vec4f, + @location(0) worldPosition: vec3f, + @location(1) normal: vec3f, + @location(2) tangent: vec3f, + @location(3) bitangent: vec3f, + @location(4) uv: vec2f, +} + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + let world = vec4f(in.position, 1.0); + out.clipPosition = scene.viewProjectionMatrix * world; + out.worldPosition = world.xyz; + out.normal = normalize(in.normal); + out.tangent = normalize(in.tangent.xyz); + out.bitangent = normalize(cross(out.normal, out.tangent) * in.tangent.w); + out.uv = in.uv; + return out; +} + +${brdf} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4f { + let baseColor = textureSample(baseColorTexture, pbrSampler, in.uv) * material.baseColorFactor; + + let mr = textureSample(metallicRoughnessTexture, pbrSampler, in.uv); + let metallic = mr.b * material.metallicFactor; + var roughness = mr.g * material.roughnessFactor; + roughness = max(roughness, MIN_ROUGHNESS); + let alpha = roughness * roughness; + + let occlusion = textureSample(occlusionTexture, pbrSampler, in.uv).r; + let emissive = textureSample(emissiveTexture, pbrSampler, in.uv).rgb * material.emissiveFactor; + + let nSampled = textureSample(normalTexture, pbrSampler, in.uv).rgb * 2.0 - vec3f(1.0); + let nScaled = vec3f(nSampled.xy * material.normalScale, nSampled.z); + let tbn = mat3x3(in.tangent, in.bitangent, in.normal); + let N = normalize(tbn * nScaled); + + let V = normalize(scene.cameraPosition - in.worldPosition); + let nDotV = max(dot(N, V), 0.001); + let R = reflect(-V, N); + + let f0 = mix(vec3f(0.04), baseColor.rgb, metallic); + + // --- IBL contribution (split-sum) --- + let F_ibl = f_schlick_roughness(nDotV, f0, roughness); + let kD_ibl = (vec3f(1.0) - F_ibl) * (1.0 - metallic); + + let irradiance = textureSampleLevel(iblIrradiance, iblSampler, N, 0.0).rgb; + let diffuseIbl = kD_ibl * irradiance * baseColor.rgb; + + let mipLevel = roughness * (PREFILTERED_MIP_COUNT - 1.0); + let prefiltered = textureSampleLevel(iblPrefiltered, iblSampler, R, mipLevel).rgb; + let envBrdf = textureSampleLevel(iblBrdfLut, iblSampler, vec2f(nDotV, roughness), 0.0).rg; + let specularIbl = prefiltered * (F_ibl * envBrdf.x + envBrdf.y); + + let ambient = (diffuseIbl + specularIbl) * mix(1.0, occlusion, material.occlusionStrength); + + // --- Direct light (keeps parity with the direct renderer's single light) --- + let L = normalize(-scene.lightDirection); + let H = normalize(V + L); + let nDotL = max(dot(N, L), 0.0); + let nDotH = max(dot(N, H), 0.0); + let vDotH = max(dot(V, H), 0.0); + let D_d = d_ggx(nDotH, alpha); + let G_d = g_smith(nDotV, nDotL, alpha); + let F_d = f_schlick(vDotH, f0); + let spec_d = (D_d * G_d * F_d) / (4.0 * nDotV * nDotL + 0.0001); + let kD_d = (vec3f(1.0) - F_d) * (1.0 - metallic); + let direct = (kD_d * baseColor.rgb / PI + spec_d) * scene.lightColor * nDotL; + + let color = direct + ambient + emissive; + let mapped = tone_map_aces(color); + let gamma = pow(mapped, vec3f(1.0 / 2.2)); + return vec4f(gamma, baseColor.a); +} +`; +} diff --git a/packages/data-graphics/src/pbr/plugins/pbr-core.ts b/packages/data-graphics/src/pbr/plugins/pbr-core.ts new file mode 100644 index 00000000..47f1407d --- /dev/null +++ b/packages/data-graphics/src/pbr/plugins/pbr-core.ts @@ -0,0 +1,46 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; + +/** + * Shape of an inserted PBR primitive. All five fields are GPU handles produced + * by `loadGltfModel` — never authored by the user. + */ +export interface PbrPrimitiveInsert { + pbrVertexBuffer: GPUBuffer; + pbrIndexBuffer: GPUBuffer; + pbrIndexCount: number; + pbrIndexFormat: GPUIndexFormat; + pbrMaterialBindGroup: GPUBindGroup; +} + +/** + * Data-only PBR plugin. Declares the component shape that any PBR renderer + * consumes, plus the transaction that loaders call to spawn primitives. No + * render system, no scene-uniforms dependency, no shader. + * + * The PbrPrimitive archetype includes `ephemeral`, which routes its entities + * to the non-persisted location table — appropriate because every component + * here is a GPU handle derived at runtime, not user-authored state. + * + * Pair with one of `pbrDirect` or (future) `pbrIbl` to actually draw entities. + */ +export const pbrCore = Database.Plugin.create({ + components: { + pbrVertexBuffer: { default: null as unknown as GPUBuffer }, + pbrIndexBuffer: { default: null as unknown as GPUBuffer }, + pbrIndexCount: { default: 0 as number }, + pbrIndexFormat: { default: "uint16" as GPUIndexFormat }, + pbrMaterialBindGroup: { default: null as unknown as GPUBindGroup }, + }, + archetypes: { + PbrPrimitive: ["ephemeral", "pbrVertexBuffer", "pbrIndexBuffer", "pbrIndexCount", "pbrIndexFormat", "pbrMaterialBindGroup"], + }, + transactions: { + pbrInsertPrimitives(t, primitives: readonly PbrPrimitiveInsert[]) { + for (const p of primitives) { + t.archetypes.PbrPrimitive.insert({ ephemeral: true, ...p }); + } + }, + }, +}); diff --git a/packages/data-graphics/src/pbr/plugins/pbr-direct.ts b/packages/data-graphics/src/pbr/plugins/pbr-direct.ts new file mode 100644 index 00000000..04233eb9 --- /dev/null +++ b/packages/data-graphics/src/pbr/plugins/pbr-direct.ts @@ -0,0 +1,102 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; +import { defaultSceneUniforms } from "../../plugins/default-scene-uniforms.js"; +import { StandardVertex } from "../types/standard-vertex/standard-vertex.js"; +import { createMaterialBindGroupLayout, createSceneBindGroupLayout } from "../bind-group-layouts.js"; +import { pbrCore } from "./pbr-core.js"; +import shaderSource from "./direct-shader.wgsl.js"; + +/** + * Direct-lighting PBR renderer. Renders any entity matching the `pbrCore` + * `PbrPrimitive` archetype using a metallic-roughness Cook-Torrance BRDF + * driven by the scene's single directional light + flat ambient. + * + * Pick this OR (future) `pbrIbl` — not both, since both would iterate the + * same archetype. + */ +export const pbrDirect = Database.Plugin.create({ + extends: Database.Plugin.combine(pbrCore, defaultSceneUniforms), + systems: { + pbrDirectRender: { + create: db => { + let pipeline: GPURenderPipeline | null = null; + let sceneLayout: GPUBindGroupLayout | null = null; + let materialLayout: GPUBindGroupLayout | null = null; + let sceneBindGroup: GPUBindGroup | null = null; + let cachedSceneBuffer: GPUBuffer | null = null; + + return () => { + const { device, renderPassEncoder, canvasFormat, depthFormat, sceneUniformsBuffer } = db.store.resources; + if (!device || !renderPassEncoder || !sceneUniformsBuffer) return; + + if (!sceneLayout) sceneLayout = createSceneBindGroupLayout(device); + if (!materialLayout) materialLayout = createMaterialBindGroupLayout(device); + + if (!pipeline) { + const module = device.createShaderModule({ code: shaderSource }); + pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [sceneLayout, materialLayout], + }), + vertex: { + module, + entryPoint: "vs_main", + buffers: [StandardVertex.layout], + }, + fragment: { + module, + entryPoint: "fs_main", + targets: [{ format: canvasFormat }], + }, + primitive: { topology: "triangle-list", cullMode: "back" }, + depthStencil: { + format: depthFormat, + depthWriteEnabled: true, + depthCompare: "less", + }, + }); + } + + if (sceneUniformsBuffer !== cachedSceneBuffer || !sceneBindGroup) { + sceneBindGroup = device.createBindGroup({ + layout: sceneLayout, + entries: [{ binding: 0, resource: { buffer: sceneUniformsBuffer } }], + }); + cachedSceneBuffer = sceneUniformsBuffer; + } + + renderPassEncoder.setPipeline(pipeline); + renderPassEncoder.setBindGroup(0, sceneBindGroup); + + for (const archetype of db.store.queryArchetypes([ + "pbrVertexBuffer", + "pbrIndexBuffer", + "pbrIndexCount", + "pbrIndexFormat", + "pbrMaterialBindGroup", + ])) { + const vbs = archetype.columns.pbrVertexBuffer; + const ibs = archetype.columns.pbrIndexBuffer; + const counts = archetype.columns.pbrIndexCount; + const formats = archetype.columns.pbrIndexFormat; + const groups = archetype.columns.pbrMaterialBindGroup; + for (let i = 0; i < archetype.rowCount; i++) { + const vb = vbs.get(i); + const ib = ibs.get(i); + const count = counts.get(i); + const fmt = formats.get(i); + const mat = groups.get(i); + if (!vb || !ib || !count || !mat) continue; + renderPassEncoder.setVertexBuffer(0, vb); + renderPassEncoder.setIndexBuffer(ib, fmt); + renderPassEncoder.setBindGroup(1, mat); + renderPassEncoder.drawIndexed(count); + } + } + }; + }, + schedule: { during: ["render"] } + } + } +}); diff --git a/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts b/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts new file mode 100644 index 00000000..b32b7cc8 --- /dev/null +++ b/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts @@ -0,0 +1,263 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; +import { Vec3 } from "@adobe/data/math"; +import { defaultSceneUniforms } from "../../plugins/default-scene-uniforms.js"; +import { createMaterialBindGroupLayout, createSceneBindGroupLayout } from "../bind-group-layouts.js"; +import { buildIblResources } from "../ibl/build-ibl-resources.js"; +import { parseHdr } from "../ibl/parse-hdr.js"; +import { StandardVertex } from "../types/standard-vertex/standard-vertex.js"; +import { pbrCore } from "./pbr-core.js"; +import { buildIblShader } from "./ibl-shader.wgsl.js"; +import skyboxShader from "./skybox-shader.wgsl.js"; + +const PREFILTERED_MIP_COUNT = 7; + +function createIblBindGroupLayout(device: GPUDevice): GPUBindGroupLayout { + return device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "cube" } }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "cube" } }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d" } }, + { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }, + ], + }); +} + +function createSkyboxBindGroupLayout(device: GPUDevice): GPUBindGroupLayout { + return device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "cube" } }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }, + ], + }); +} + +/** + * Image-Based Lighting PBR renderer. Precomputes a procedural studio (or HDR- + * sourced) environment + diffuse irradiance + GGX-prefiltered specular + BRDF + * LUT once when the GPU device is available, then renders the environment as + * a skybox followed by all `PbrPrimitive` entities using split-sum IBL plus a + * single-light direct contribution. + * + * Mutually exclusive with `pbrDirect` — both iterate the same archetype. + */ +export const pbrIbl = Database.Plugin.create({ + extends: Database.Plugin.combine(pbrCore, defaultSceneUniforms), + resources: { + iblEnvironmentUrl: { default: null as string | null, transient: true }, + iblEnvironmentMap: { default: null as GPUTexture | null, transient: true }, + iblIrradianceMap: { default: null as GPUTexture | null, transient: true }, + iblPrefilteredMap: { default: null as GPUTexture | null, transient: true }, + iblBrdfLut: { default: null as GPUTexture | null, transient: true }, + }, + transactions: { + setIblEnvironmentUrl(t, url: string | null) { + t.resources.iblEnvironmentUrl = url; + }, + }, + systems: { + pbrIblInit: { + create: db => { + let started = false; + return () => { + if (started) return; + const { device, iblEnvironmentUrl } = db.store.resources; + if (!device) return; + started = true; + + const buildAndAssign = (hdr?: Awaited>) => { + const r = buildIblResources(device, { + prefilteredMipCount: PREFILTERED_MIP_COUNT, + hdrSource: hdr, + }); + db.store.resources.iblEnvironmentMap = r.environment; + db.store.resources.iblIrradianceMap = r.irradiance; + db.store.resources.iblPrefilteredMap = r.prefiltered; + db.store.resources.iblBrdfLut = r.brdfLut; + }; + + if (iblEnvironmentUrl) { + fetch(iblEnvironmentUrl) + .then(r => { + if (!r.ok) throw new Error(`HDR fetch failed: ${r.status}`); + return r.arrayBuffer(); + }) + .then(buf => { + buildAndAssign(parseHdr(buf)); + }) + .catch(err => { + console.warn("[pbrIbl] HDR load failed; using procedural fallback", err); + buildAndAssign(); + }); + } else { + buildAndAssign(); + } + }; + }, + schedule: { during: ["preRender"] } + }, + pbrIblRender: { + create: db => { + let pipeline: GPURenderPipeline | null = null; + let skyboxPipeline: GPURenderPipeline | null = null; + let sceneLayout: GPUBindGroupLayout | null = null; + let materialLayout: GPUBindGroupLayout | null = null; + let iblLayout: GPUBindGroupLayout | null = null; + let skyboxLayout: GPUBindGroupLayout | null = null; + let iblSampler: GPUSampler | null = null; + let skyboxUniformBuffer: GPUBuffer | null = null; + let sceneBindGroup: GPUBindGroup | null = null; + let iblBindGroup: GPUBindGroup | null = null; + let skyboxBindGroup: GPUBindGroup | null = null; + let cachedSceneBuffer: GPUBuffer | null = null; + let cachedIblIrradiance: GPUTexture | null = null; + let cachedSkyEnvironment: GPUTexture | null = null; + // std140 layout: 3 × vec3 each followed by a scalar packed + // into the 4th lane. 48 bytes total. + const skyboxScratch = new Float32Array(12); + + return () => { + const { + device, renderPassEncoder, canvasFormat, depthFormat, sceneUniformsBuffer, + iblEnvironmentMap, iblIrradianceMap, iblPrefilteredMap, iblBrdfLut, camera, + } = db.store.resources; + if (!device || !renderPassEncoder || !sceneUniformsBuffer || !camera) return; + if (!iblEnvironmentMap || !iblIrradianceMap || !iblPrefilteredMap || !iblBrdfLut) return; + + if (!sceneLayout) sceneLayout = createSceneBindGroupLayout(device); + if (!materialLayout) materialLayout = createMaterialBindGroupLayout(device); + if (!iblLayout) iblLayout = createIblBindGroupLayout(device); + if (!skyboxLayout) skyboxLayout = createSkyboxBindGroupLayout(device); + if (!iblSampler) { + iblSampler = device.createSampler({ + magFilter: "linear", + minFilter: "linear", + mipmapFilter: "linear", + addressModeU: "clamp-to-edge", + addressModeV: "clamp-to-edge", + addressModeW: "clamp-to-edge", + }); + } + if (!skyboxUniformBuffer) { + skyboxUniformBuffer = device.createBuffer({ + size: 48, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + } + + if (!pipeline) { + const module = device.createShaderModule({ + code: buildIblShader({ prefilteredMipCount: PREFILTERED_MIP_COUNT }), + }); + pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [sceneLayout, materialLayout, iblLayout], + }), + vertex: { module, entryPoint: "vs_main", buffers: [StandardVertex.layout] }, + fragment: { module, entryPoint: "fs_main", targets: [{ format: canvasFormat }] }, + primitive: { topology: "triangle-list", cullMode: "back" }, + depthStencil: { format: depthFormat, depthWriteEnabled: true, depthCompare: "less" }, + }); + } + if (!skyboxPipeline) { + const sm = device.createShaderModule({ code: skyboxShader }); + skyboxPipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ bindGroupLayouts: [skyboxLayout] }), + vertex: { module: sm, entryPoint: "vs_skybox" }, + fragment: { module: sm, entryPoint: "fs_skybox", targets: [{ format: canvasFormat }] }, + primitive: { topology: "triangle-list" }, + depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" }, + }); + } + + if (sceneUniformsBuffer !== cachedSceneBuffer || !sceneBindGroup) { + sceneBindGroup = device.createBindGroup({ + layout: sceneLayout, + entries: [{ binding: 0, resource: { buffer: sceneUniformsBuffer } }], + }); + cachedSceneBuffer = sceneUniformsBuffer; + } + if (iblIrradianceMap !== cachedIblIrradiance || !iblBindGroup) { + iblBindGroup = device.createBindGroup({ + layout: iblLayout, + entries: [ + { binding: 0, resource: iblIrradianceMap.createView({ dimension: "cube" }) }, + { binding: 1, resource: iblPrefilteredMap.createView({ dimension: "cube" }) }, + { binding: 2, resource: iblBrdfLut.createView() }, + { binding: 3, resource: iblSampler }, + ], + }); + cachedIblIrradiance = iblIrradianceMap; + } + if (iblEnvironmentMap !== cachedSkyEnvironment || !skyboxBindGroup) { + skyboxBindGroup = device.createBindGroup({ + layout: skyboxLayout, + entries: [ + { binding: 0, resource: { buffer: skyboxUniformBuffer } }, + { binding: 1, resource: iblEnvironmentMap.createView({ dimension: "cube" }) }, + { binding: 2, resource: iblSampler }, + ], + }); + cachedSkyEnvironment = iblEnvironmentMap; + } + + // Camera basis for the skybox view rays. + const forward = Vec3.normalize(Vec3.subtract(camera.target, camera.position)); + const right = Vec3.normalize(Vec3.cross(forward, camera.up)); + const upOrtho = Vec3.cross(right, forward); + const tanHalfFov = Math.tan(camera.fieldOfView / 2); + skyboxScratch[0] = right[0]; + skyboxScratch[1] = right[1]; + skyboxScratch[2] = right[2]; + skyboxScratch[3] = camera.aspect; + skyboxScratch[4] = upOrtho[0]; + skyboxScratch[5] = upOrtho[1]; + skyboxScratch[6] = upOrtho[2]; + skyboxScratch[7] = tanHalfFov; + skyboxScratch[8] = forward[0]; + skyboxScratch[9] = forward[1]; + skyboxScratch[10] = forward[2]; + skyboxScratch[11] = 0; + device.queue.writeBuffer(skyboxUniformBuffer, 0, skyboxScratch); + + renderPassEncoder.setPipeline(skyboxPipeline); + renderPassEncoder.setBindGroup(0, skyboxBindGroup); + renderPassEncoder.draw(3); + + renderPassEncoder.setPipeline(pipeline); + renderPassEncoder.setBindGroup(0, sceneBindGroup); + renderPassEncoder.setBindGroup(2, iblBindGroup); + + for (const archetype of db.store.queryArchetypes([ + "pbrVertexBuffer", + "pbrIndexBuffer", + "pbrIndexCount", + "pbrIndexFormat", + "pbrMaterialBindGroup", + ])) { + const vbs = archetype.columns.pbrVertexBuffer; + const ibs = archetype.columns.pbrIndexBuffer; + const counts = archetype.columns.pbrIndexCount; + const formats = archetype.columns.pbrIndexFormat; + const groups = archetype.columns.pbrMaterialBindGroup; + for (let i = 0; i < archetype.rowCount; i++) { + const vb = vbs.get(i); + const ib = ibs.get(i); + const count = counts.get(i); + const fmt = formats.get(i); + const mat = groups.get(i); + if (!vb || !ib || !count || !mat) continue; + renderPassEncoder.setVertexBuffer(0, vb); + renderPassEncoder.setIndexBuffer(ib, fmt); + renderPassEncoder.setBindGroup(1, mat); + renderPassEncoder.drawIndexed(count); + } + } + }; + }, + schedule: { during: ["render"], after: ["pbrIblInit"] } + } + } +}); diff --git a/packages/data-graphics/src/pbr/plugins/skybox-shader.wgsl.ts b/packages/data-graphics/src/pbr/plugins/skybox-shader.wgsl.ts new file mode 100644 index 00000000..67c57742 --- /dev/null +++ b/packages/data-graphics/src/pbr/plugins/skybox-shader.wgsl.ts @@ -0,0 +1,51 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import brdf from "./brdf.wgsl.js"; + +// Skybox shader. Computes the view-ray direction in the vertex shader from the +// camera's orthonormal basis + half-FOV — bypasses the perspective matrix +// inverse, which would otherwise depend on the project's specific +// clip-space convention. Depth state is "always" with no write so primitives +// drawn afterward in the same render pass overlap correctly. +export default /* wgsl */ ` +struct SkyboxUniforms { + right: vec3f, + aspect: f32, + up: vec3f, + tanHalfFov: f32, + forward: vec3f, + _pad: f32, +} +@group(0) @binding(0) var sky: SkyboxUniforms; +@group(0) @binding(1) var skyCubemap: texture_cube; +@group(0) @binding(2) var skySampler: sampler; + +struct SkyboxOutput { + @builtin(position) clip: vec4f, + @location(0) dir: vec3f, +} + +@vertex +fn vs_skybox(@builtin(vertex_index) i: u32) -> SkyboxOutput { + let xs = array(-1.0, 3.0, -1.0); + let ys = array(-1.0, -1.0, 3.0); + let xn = xs[i]; + let yn = ys[i]; + var out: SkyboxOutput; + out.clip = vec4f(xn, yn, 0.0, 1.0); + out.dir = sky.forward + + sky.right * (xn * sky.aspect * sky.tanHalfFov) + + sky.up * (yn * sky.tanHalfFov); + return out; +} + +${brdf} + +@fragment +fn fs_skybox(in: SkyboxOutput) -> @location(0) vec4f { + let dir = normalize(in.dir); + let raw = textureSampleLevel(skyCubemap, skySampler, dir, 0.0).rgb; + let mapped = tone_map_aces(raw); + return vec4f(pow(mapped, vec3f(1.0 / 2.2)), 1.0); +} +`; diff --git a/packages/data-graphics/src/pbr/types/pbr-material/pbr-material.ts b/packages/data-graphics/src/pbr/types/pbr-material/pbr-material.ts new file mode 100644 index 00000000..54e784b9 --- /dev/null +++ b/packages/data-graphics/src/pbr/types/pbr-material/pbr-material.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Schema } from "@adobe/data/schema"; +import { schema } from "./schema.js"; + +export type PbrMaterial = Schema.ToType; + +export * as PbrMaterial from "./public.js"; diff --git a/packages/data-graphics/src/pbr/types/pbr-material/public.ts b/packages/data-graphics/src/pbr/types/pbr-material/public.ts new file mode 100644 index 00000000..c6359caf --- /dev/null +++ b/packages/data-graphics/src/pbr/types/pbr-material/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export * from "./schema.js"; diff --git a/packages/data-graphics/src/pbr/types/pbr-material/schema.ts b/packages/data-graphics/src/pbr/types/pbr-material/schema.ts new file mode 100644 index 00000000..4a54237a --- /dev/null +++ b/packages/data-graphics/src/pbr/types/pbr-material/schema.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { F32, Vec3, Vec4 } from "@adobe/data/math"; +import { Schema } from "@adobe/data/schema"; + +export const schema = { + type: "object", + properties: { + baseColorFactor: Vec4.schema, + emissiveFactor: Vec3.schema, + metallicFactor: F32.schema, + roughnessFactor: F32.schema, + normalScale: F32.schema, + occlusionStrength: F32.schema, + }, + required: [ + "baseColorFactor", + "emissiveFactor", + "metallicFactor", + "roughnessFactor", + "normalScale", + "occlusionStrength", + ], + additionalProperties: false, +} as const satisfies Schema; diff --git a/packages/data-graphics/src/pbr/types/standard-vertex/layout.ts b/packages/data-graphics/src/pbr/types/standard-vertex/layout.ts new file mode 100644 index 00000000..376da5ef --- /dev/null +++ b/packages/data-graphics/src/pbr/types/standard-vertex/layout.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { getStructLayout } from "@adobe/data/typed-buffer"; +import { schema } from "./schema.js"; + +const sl = getStructLayout(schema); + +export const layout: GPUVertexBufferLayout = { + arrayStride: sl.size, + stepMode: "vertex", + attributes: [ + { format: "float32x3", offset: sl.fields["position"]!.offset, shaderLocation: 0 }, + { format: "float32x3", offset: sl.fields["normal"]!.offset, shaderLocation: 1 }, + { format: "float32x4", offset: sl.fields["tangent"]!.offset, shaderLocation: 2 }, + { format: "float32x2", offset: sl.fields["uv"]!.offset, shaderLocation: 3 }, + ], +}; + +export const stride = sl.size; diff --git a/packages/data-graphics/src/pbr/types/standard-vertex/public.ts b/packages/data-graphics/src/pbr/types/standard-vertex/public.ts new file mode 100644 index 00000000..fbcfe4cc --- /dev/null +++ b/packages/data-graphics/src/pbr/types/standard-vertex/public.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export * from "./schema.js"; +export * from "./layout.js"; diff --git a/packages/data-graphics/src/pbr/types/standard-vertex/schema.ts b/packages/data-graphics/src/pbr/types/standard-vertex/schema.ts new file mode 100644 index 00000000..79b87e26 --- /dev/null +++ b/packages/data-graphics/src/pbr/types/standard-vertex/schema.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Vec2, Vec3, Vec4 } from "@adobe/data/math"; +import { Schema } from "@adobe/data/schema"; + +export const schema = { + type: "object", + properties: { + position: Vec3.schema, + normal: Vec3.schema, + tangent: Vec4.schema, + uv: Vec2.schema, + }, + required: ["position", "normal", "tangent", "uv"], + additionalProperties: false, + layout: "packed", +} as const satisfies Schema; diff --git a/packages/data-graphics/src/pbr/types/standard-vertex/standard-vertex.ts b/packages/data-graphics/src/pbr/types/standard-vertex/standard-vertex.ts new file mode 100644 index 00000000..37ad1eab --- /dev/null +++ b/packages/data-graphics/src/pbr/types/standard-vertex/standard-vertex.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Schema } from "@adobe/data/schema"; +import { schema } from "./schema.js"; + +export type StandardVertex = Schema.ToType; + +export * as StandardVertex from "./public.js"; diff --git a/packages/data-graphics/src/plugins/camera.ts b/packages/data-graphics/src/plugins/camera.ts new file mode 100644 index 00000000..17fee9cf --- /dev/null +++ b/packages/data-graphics/src/plugins/camera.ts @@ -0,0 +1,37 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; +import { graphics } from "./graphics.js"; +import { Camera } from "../types/camera/camera.js"; + +export const camera = Database.Plugin.create({ + extends: graphics, + resources: { + camera: { + default: { + aspect: 16 / 9, + fieldOfView: Math.PI / 4, + nearPlane: 0.1, + farPlane: 100.0, + position: [0, 0, 10], + target: [0, 0, 0], + up: [0, 1, 0], + orthographic: 0, + } satisfies Camera as Camera, + }, + }, + systems: { + updateCameraAspect: { + create: db => () => { + const { canvas } = db.store.resources; + let { camera: cam } = db.store.resources; + if (!canvas || !cam) return; + const aspect = canvas.width / canvas.height; + if (cam.aspect !== aspect) { + db.store.resources.camera = { ...cam, aspect }; + } + }, + schedule: { during: ["preRender"] } + }, + }, +}); diff --git a/packages/data-graphics/src/plugins/default-scene-uniforms.ts b/packages/data-graphics/src/plugins/default-scene-uniforms.ts new file mode 100644 index 00000000..5e5297be --- /dev/null +++ b/packages/data-graphics/src/plugins/default-scene-uniforms.ts @@ -0,0 +1,60 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; +import { F32, Vec3 } from "@adobe/data/math"; +import { createStructBuffer, copyToGPUBuffer, getStructLayout, type TypedBuffer } from "@adobe/data/typed-buffer"; +import { camera } from "./camera.js"; +import { Camera } from "../types/camera/camera.js"; +import { SceneUniforms } from "../types/scene-uniforms/scene-uniforms.js"; + +const sceneUniformsStructLayout = getStructLayout(SceneUniforms.schema); + +export const defaultSceneUniforms = Database.Plugin.create({ + extends: camera, + resources: { + sceneUniformsBuffer: { default: null as GPUBuffer | null, transient: true }, + lightDirection: { default: Vec3.normalize([-1, -3, -10]) as Vec3 }, + ambientStrength: { default: 0.5 as F32 }, + lightColor: { default: [1.0, 1.0, 1.0] as Vec3 }, + }, + transactions: { + setLight(t, args: { direction?: Vec3; color?: Vec3 }) { + if (args.direction !== undefined) t.resources.lightDirection = Vec3.normalize(args.direction); + if (args.color !== undefined) t.resources.lightColor = args.color; + }, + }, + systems: { + updateSceneUniforms: { + create: db => { + let structBuffer: TypedBuffer | null = null; + return () => { + const { device, lightDirection, ambientStrength, lightColor } = db.store.resources; + const cam = db.store.resources.camera; + if (!device || !cam) return; + + structBuffer ??= createStructBuffer(SceneUniforms.schema, sceneUniformsStructLayout.size); + + let gpuBuffer = db.store.resources.sceneUniformsBuffer; + if (!gpuBuffer) { + gpuBuffer = device.createBuffer({ + size: sceneUniformsStructLayout.size, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + db.store.resources.sceneUniformsBuffer = gpuBuffer; + } + + structBuffer.set(0, { + viewProjectionMatrix: Camera.toViewProjection(cam), + lightDirection, + ambientStrength, + lightColor, + cameraPosition: cam.position, + }); + + db.store.resources.sceneUniformsBuffer = copyToGPUBuffer(structBuffer, device, gpuBuffer); + }; + }, + schedule: { during: ["preRender"] } + }, + }, +}); diff --git a/packages/data-graphics/src/plugins/graphics.ts b/packages/data-graphics/src/plugins/graphics.ts new file mode 100644 index 00000000..f5f07735 --- /dev/null +++ b/packages/data-graphics/src/plugins/graphics.ts @@ -0,0 +1,126 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database, scheduler } from "@adobe/data/ecs"; +import { Vec4 } from "@adobe/data/math"; + +async function getWebGPUDevice() { + const adapter = await navigator.gpu?.requestAdapter(); + if (!adapter) { + return null; + } + const device = await adapter.requestDevice(); + return device; +} + +export const graphics = Database.Plugin.create({ + extends: scheduler, + resources: { + device: { default: null as GPUDevice | null, transient: true }, + commandEncoder: { default: null as GPUCommandEncoder | null, transient: true }, + renderPassEncoder: { default: null as GPURenderPassEncoder | null, transient: true }, + depthTexture: { default: null as GPUTexture | null, transient: true }, + clearColor: { default: [0, 0, 0, 1] as Vec4, transient: true }, + canvas: { default: null as HTMLCanvasElement | null, transient: true }, + canvasContext: { default: null as GPUCanvasContext | null, transient: true }, + canvasFormat: { default: navigator.gpu.getPreferredCanvasFormat(), transient: true }, + depthFormat: { default: "depth24plus" as GPUTextureFormat, transient: true }, + }, + transactions: { + setCanvas(t, canvas: HTMLCanvasElement | null) { + t.resources.canvas = canvas; + } + }, + systems: { + input: { + create: _db => () => {} + }, + preUpdate: { + create: db => () => { + const { device } = db.store.resources; + if (device) { + db.store.resources.commandEncoder = device.createCommandEncoder(); + let { canvas, canvasContext, canvasFormat } = db.store.resources; + if (canvas && !canvasContext) { + canvasContext = db.store.resources.canvasContext = canvas.getContext("webgpu"); + if (!canvasContext) { + throw new Error("No WebGPU context"); + } + canvasContext.configure({ + device, + format: canvasFormat, + alphaMode: "premultiplied", + }); + } + } + }, + schedule: { after: ["input"] } + }, + update: { + create: _db => () => {}, + schedule: { after: ["preUpdate"] } + }, + postUpdate: { + create: _db => () => {}, + schedule: { after: ["update"] } + }, + physics: { + create: _db => () => {}, + schedule: { after: ["postUpdate"] } + }, + preRender: { + create: db => () => { + const { canvas, commandEncoder, clearColor, canvasContext, device, depthFormat } = db.store.resources; + let { depthTexture } = db.store.resources; + if (!commandEncoder || !canvasContext || !device || !canvas) return; + + const canvasSize: [number, number] = [canvas.width, canvas.height]; + if (!depthTexture || depthTexture.width !== canvasSize[0] || depthTexture.height !== canvasSize[1]) { + depthTexture = db.store.resources.depthTexture = device.createTexture({ + size: canvasSize, + format: depthFormat, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + } + + db.store.resources.renderPassEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [{ + clearValue: clearColor, + loadOp: "clear", + storeOp: "store", + view: canvasContext.getCurrentTexture().createView(), + }], + depthStencilAttachment: { + view: depthTexture.createView(), + depthClearValue: 1.0, + depthLoadOp: "clear", + depthStoreOp: "store", + }, + }); + }, + schedule: { after: ["physics"] } + }, + render: { + create: db => { + getWebGPUDevice().then(device => { + db.store.resources.device = device; + }); + return () => {}; + }, + schedule: { after: ["preRender"] } + }, + postRender: { + create: db => () => { + const { commandEncoder, renderPassEncoder, device } = db.store.resources; + if (renderPassEncoder) { + renderPassEncoder.end(); + db.store.resources.renderPassEncoder = null; + } + if (commandEncoder && device) { + device.queue.submit([commandEncoder.finish()]); + db.store.resources.commandEncoder = null; + } + }, + schedule: { after: ["render"] } + }, + }, +}); diff --git a/packages/data-graphics/src/plugins/node.ts b/packages/data-graphics/src/plugins/node.ts new file mode 100644 index 00000000..4d0a153d --- /dev/null +++ b/packages/data-graphics/src/plugins/node.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; +import { Quat, Vec3 } from "@adobe/data/math"; +import { True } from "@adobe/data/schema"; + +export const node = Database.Plugin.create({ + components: { + visible: True.schema, + position: Vec3.schema, + rotation: Quat.schema, + scale: Vec3.schema, + }, +}); diff --git a/packages/data-graphics/src/types/camera/camera.ts b/packages/data-graphics/src/types/camera/camera.ts new file mode 100644 index 00000000..cd623328 --- /dev/null +++ b/packages/data-graphics/src/types/camera/camera.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Schema } from "@adobe/data/schema"; +import { schema } from "./schema.js"; + +export type Camera = Schema.ToType; + +export * as Camera from "./public.js"; diff --git a/packages/data-graphics/src/types/camera/public.ts b/packages/data-graphics/src/types/camera/public.ts new file mode 100644 index 00000000..324b0a15 --- /dev/null +++ b/packages/data-graphics/src/types/camera/public.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export * from "./schema.js"; +export * from "./to-view-projection.js"; +export * from "./screen-to-world-ray.js"; diff --git a/packages/data-graphics/src/types/camera/schema.ts b/packages/data-graphics/src/types/camera/schema.ts new file mode 100644 index 00000000..a8ba0f12 --- /dev/null +++ b/packages/data-graphics/src/types/camera/schema.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { F32, Vec3 } from "@adobe/data/math"; +import { Schema } from "@adobe/data/schema"; + +export const schema = { + type: "object", + properties: { + aspect: F32.schema, + fieldOfView: F32.schema, + nearPlane: F32.schema, + farPlane: F32.schema, + position: Vec3.schema, + target: Vec3.schema, + up: Vec3.schema, + // 0 = perspective, 1 = orthographic, fractional = hybrid blend + orthographic: F32.schema, + }, + required: ["aspect", "fieldOfView", "nearPlane", "farPlane", "position", "target", "up", "orthographic"], + additionalProperties: false, +} as const satisfies Schema; diff --git a/packages/data-graphics/src/types/camera/screen-to-world-ray.ts b/packages/data-graphics/src/types/camera/screen-to-world-ray.ts new file mode 100644 index 00000000..e349c942 --- /dev/null +++ b/packages/data-graphics/src/types/camera/screen-to-world-ray.ts @@ -0,0 +1,39 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Mat4x4, Vec3, Vec4, Line3 } from "@adobe/data/math"; +import type { Camera } from "./camera.js"; +import { toViewProjection } from "./to-view-projection.js"; + +export const screenToWorldRay = ( + cam: Camera, + screenX: number, + screenY: number, + canvasWidth: number, + canvasHeight: number, + rayLength = 1000, +): Line3 => { + const ndcX = (screenX / canvasWidth) * 2 - 1; + const ndcY = 1 - (screenY / canvasHeight) * 2; + + // WebGPU NDC z ∈ [0, 1]: near plane at 0, far plane at 1. + const nearPoint: Vec4 = [ndcX, ndcY, 0, 1]; + const farPoint: Vec4 = [ndcX, ndcY, 1, 1]; + + const invVP = Mat4x4.inverse(toViewProjection(cam)); + + const nearW = Mat4x4.multiplyVec4(invVP, nearPoint); + const farW = Mat4x4.multiplyVec4(invVP, farPoint); + + const near: Vec3 = [nearW[0] / nearW[3], nearW[1] / nearW[3], nearW[2] / nearW[3]]; + const far: Vec3 = [farW[0] / farW[3], farW[1] / farW[3], farW[2] / farW[3]]; + + const dir: Vec3 = [near[0] - far[0], near[1] - far[1], near[2] - far[2]]; + const len = Math.sqrt(dir[0] ** 2 + dir[1] ** 2 + dir[2] ** 2); + + if (len < 0.0001) return { a: near, b: far }; + + const norm: Vec3 = [dir[0] / len, dir[1] / len, dir[2] / len]; + const end: Vec3 = [near[0] + norm[0] * rayLength, near[1] + norm[1] * rayLength, near[2] + norm[2] * rayLength]; + + return { a: near, b: end }; +}; diff --git a/packages/data-graphics/src/types/camera/to-view-projection.ts b/packages/data-graphics/src/types/camera/to-view-projection.ts new file mode 100644 index 00000000..ddb19f0e --- /dev/null +++ b/packages/data-graphics/src/types/camera/to-view-projection.ts @@ -0,0 +1,29 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Mat4x4, Vec3 } from "@adobe/data/math"; +import type { Camera } from "./camera.js"; + +export const toViewProjection = (cam: Camera): Mat4x4 => { + const lookAt = Mat4x4.lookAt(cam.position, cam.target, cam.up); + + const f = 1.0 / Math.tan(cam.fieldOfView / 2); + const d = Vec3.distance(cam.position, cam.target); + + // WebGPU clip-space convention: z_ndc ∈ [0, 1]. + // Perspective wants m[2][2] = far/(near-far); orthographic wants d/(near-far) + // so that z_clip(z_view = -far) = (perspective: far / w_clip=far → 1) + // (orthographic: d / w_clip=d → 1). + // Blend the two cases by the orthographic factor. + const m22 = ((1 - cam.orthographic) * cam.farPlane + cam.orthographic * d) / (cam.nearPlane - cam.farPlane); + const m23 = cam.nearPlane * m22; + + // Fourth row blends: w' = (1 - orthographic) * (-z) + orthographic * d + const perspective: Mat4x4 = [ + f / cam.aspect, 0, 0, 0, + 0, f, 0, 0, + 0, 0, m22, -(1 - cam.orthographic), + 0, 0, m23, cam.orthographic * d, + ]; + + return Mat4x4.multiply(perspective, lookAt); +}; diff --git a/packages/data-graphics/src/types/scene-uniforms/public.ts b/packages/data-graphics/src/types/scene-uniforms/public.ts new file mode 100644 index 00000000..c6359caf --- /dev/null +++ b/packages/data-graphics/src/types/scene-uniforms/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export * from "./schema.js"; diff --git a/packages/data-graphics/src/types/scene-uniforms/scene-uniforms.ts b/packages/data-graphics/src/types/scene-uniforms/scene-uniforms.ts new file mode 100644 index 00000000..439644fe --- /dev/null +++ b/packages/data-graphics/src/types/scene-uniforms/scene-uniforms.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Schema } from "@adobe/data/schema"; +import { schema } from "./schema.js"; + +/** + * Default scene uniform struct for a single-viewport, single-directional-light setup. + * Use `defaultSceneUniforms` plugin to have this written to a GPU buffer each frame. + * + * This is a starting point, not a required contract. Consumers that need different + * fields (multiple lights, fog, time, etc.) should define their own schema + plugin. + */ +export type SceneUniforms = Schema.ToType; + +export * as SceneUniforms from "./public.js"; diff --git a/packages/data-graphics/src/types/scene-uniforms/schema.ts b/packages/data-graphics/src/types/scene-uniforms/schema.ts new file mode 100644 index 00000000..00970d73 --- /dev/null +++ b/packages/data-graphics/src/types/scene-uniforms/schema.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { F32, Mat4x4, Vec3 } from "@adobe/data/math"; +import { Schema } from "@adobe/data/schema"; + +export const schema = { + type: "object", + properties: { + viewProjectionMatrix: Mat4x4.schema, + lightDirection: Vec3.schema, + ambientStrength: F32.schema, + lightColor: Vec3.schema, + cameraPosition: Vec3.schema, + }, + required: ["viewProjectionMatrix", "lightDirection", "ambientStrength", "lightColor", "cameraPosition"], + additionalProperties: false, +} as const satisfies Schema; diff --git a/packages/data-graphics/src/types/vertices/position-color-normal/layout.ts b/packages/data-graphics/src/types/vertices/position-color-normal/layout.ts new file mode 100644 index 00000000..a393e7e0 --- /dev/null +++ b/packages/data-graphics/src/types/vertices/position-color-normal/layout.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { getStructLayout } from "@adobe/data/typed-buffer"; +import { schema } from "./schema.js"; + +const sl = getStructLayout(schema); + +export const layout: GPUVertexBufferLayout = { + arrayStride: sl.size, + stepMode: "vertex", + attributes: [ + { format: "float32x3", offset: sl.fields["position"]!.offset, shaderLocation: 0 }, + { format: "float32x4", offset: sl.fields["color"]!.offset, shaderLocation: 1 }, + { format: "float32x3", offset: sl.fields["normal"]!.offset, shaderLocation: 2 }, + ], +}; diff --git a/packages/data-graphics/src/types/vertices/position-color-normal/position-color-normal.ts b/packages/data-graphics/src/types/vertices/position-color-normal/position-color-normal.ts new file mode 100644 index 00000000..0d1fcf14 --- /dev/null +++ b/packages/data-graphics/src/types/vertices/position-color-normal/position-color-normal.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Schema } from "@adobe/data/schema"; +import { schema } from "./schema.js"; + +export type PositionColorNormalVertex = Schema.ToType; + +export * as PositionColorNormalVertex from "./public.js"; diff --git a/packages/data-graphics/src/types/vertices/position-color-normal/public.ts b/packages/data-graphics/src/types/vertices/position-color-normal/public.ts new file mode 100644 index 00000000..fbcfe4cc --- /dev/null +++ b/packages/data-graphics/src/types/vertices/position-color-normal/public.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export * from "./schema.js"; +export * from "./layout.js"; diff --git a/packages/data-graphics/src/types/vertices/position-color-normal/schema.ts b/packages/data-graphics/src/types/vertices/position-color-normal/schema.ts new file mode 100644 index 00000000..2de3ee16 --- /dev/null +++ b/packages/data-graphics/src/types/vertices/position-color-normal/schema.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Vec3, Vec4 } from "@adobe/data/math"; +import { Schema } from "@adobe/data/schema"; + +export const schema = { + type: "object", + properties: { + position: Vec3.schema, + color: Vec4.schema, + normal: Vec3.schema, + }, + required: ["position", "color", "normal"], + additionalProperties: false, + layout: "packed", +} as const satisfies Schema; diff --git a/packages/data-graphics/tsconfig.json b/packages/data-graphics/tsconfig.json new file mode 100644 index 00000000..c6a26cf6 --- /dev/null +++ b/packages/data-graphics/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES5", "ES2015", "ES2016", "ES2017", "ES2018", "ES2019", "ES2020", "ES2021", "ES2022", "ES2023", "ESNext", "DOM", "DOM.Iterable"], + "types": ["@webgpu/types"], + "experimentalDecorators": true, + "useDefineForClassFields": false, + "skipLibCheck": true, + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "resolveJsonModule": true, + "isolatedModules": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "stripInternal": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [] +} diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index fe46a68b..cf1f976a 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -17,6 +17,7 @@ }, "devDependencies": { "typescript": "^5.8.3", - "vite": "^5.1.1" + "vite": "^5.1.1", + "vite-plugin-checker": "^0.12.0" } } diff --git a/packages/data-p2p-tictactoe/vite.config.ts b/packages/data-p2p-tictactoe/vite.config.ts index 48fe3b9d..aa270bbf 100644 --- a/packages/data-p2p-tictactoe/vite.config.ts +++ b/packages/data-p2p-tictactoe/vite.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "vite"; +import checker from "vite-plugin-checker"; export default defineConfig({ + plugins: [checker({ typescript: true })], optimizeDeps: { esbuildOptions: { tsconfigRaw: { diff --git a/packages/data-react-hello/package.json b/packages/data-react-hello/package.json index 8038cd43..8ac2cb63 100644 --- a/packages/data-react-hello/package.json +++ b/packages/data-react-hello/package.json @@ -1,6 +1,6 @@ { "name": "data-react-hello", -"version": "0.9.56", + "version": "0.9.56", "description": "Hello World sample - click counter using @adobe/data-react", "type": "module", "private": true, @@ -18,8 +18,9 @@ "devDependencies": { "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.3.0", "typescript": "^5.8.3", "vite": "^5.1.1", - "@vitejs/plugin-react": "^4.3.0" + "vite-plugin-checker": "^0.12.0" } } diff --git a/packages/data-react-hello/vite.config.ts b/packages/data-react-hello/vite.config.ts index d3b4b453..6feb768e 100644 --- a/packages/data-react-hello/vite.config.ts +++ b/packages/data-react-hello/vite.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import checker from "vite-plugin-checker"; export default defineConfig({ - plugins: [react()], + plugins: [react(), checker({ typescript: true })], root: ".", build: { outDir: "dist" }, server: { port: 3001, open: false }, diff --git a/packages/data-solid-dashboard/package.json b/packages/data-solid-dashboard/package.json index c70ab72f..61497014 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "data-solid-dashboard", -"version": "0.9.56", + "version": "0.9.56", "description": "Mini dashboard sample — multiple components sharing one @adobe/data ECS database with SolidJS", "type": "module", "private": true, @@ -17,6 +17,7 @@ "devDependencies": { "typescript": "^5.8.3", "vite": "^5.1.1", + "vite-plugin-checker": "^0.12.0", "vite-plugin-solid": "^2.11.0" } } diff --git a/packages/data-solid-dashboard/vite.config.ts b/packages/data-solid-dashboard/vite.config.ts index a8c183b1..f550f4d3 100644 --- a/packages/data-solid-dashboard/vite.config.ts +++ b/packages/data-solid-dashboard/vite.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from "vite"; import solid from "vite-plugin-solid"; +import checker from "vite-plugin-checker"; export default defineConfig({ - plugins: [solid()], + plugins: [solid(), checker({ typescript: true })], root: ".", build: { outDir: "dist" }, server: { port: 3004, open: false }, diff --git a/packages/data/src/math/mat4x4/functions.ts b/packages/data/src/math/mat4x4/functions.ts index e10545af..61388816 100644 --- a/packages/data/src/math/mat4x4/functions.ts +++ b/packages/data/src/math/mat4x4/functions.ts @@ -208,12 +208,15 @@ export const perspective = (fovy: number, aspect: number, near: number, far: num if (far <= near) throw new Error('Far plane must be greater than near plane'); const f = 1.0 / Math.tan(fovy / 2); - const nf = near / (near - far); + // WebGPU clip-space convention: z_ndc ∈ [0, 1]. + // m[2][2] = far / (near - far) maps z_view = -far → z_ndc = 1 + // m[2][3] = (near*far) / (near-far) and m[3][2] = -1 maps z_view = -near → z_ndc = 0 + const fn = far / (near - far); return [ f / aspect, 0, 0, 0, 0, f, 0, 0, - 0, 0, nf, -1, - 0, 0, near * nf, 0 + 0, 0, fn, -1, + 0, 0, near * fn, 0 ]; }; diff --git a/packages/data/src/schema/index.ts b/packages/data/src/schema/index.ts index bf27d369..852dcf44 100644 --- a/packages/data/src/schema/index.ts +++ b/packages/data/src/schema/index.ts @@ -16,3 +16,5 @@ export * from "./boolean/index.js"; // these are both math types and basic schema types. export { F32, I32, U32, F64 } from "../math/index.js"; export * from "./time/index.js"; +export { toVertexBufferLayout, toVertexBufferLayoutForType } from "./to-vertex-buffer-layout.js"; +export type { GPUVertexBufferLayout, GPUVertexAttributeDescriptor, GPUVertexFormat } from "./to-vertex-buffer-layout.js"; diff --git a/packages/data/src/typed-buffer/structs/index.ts b/packages/data/src/typed-buffer/structs/index.ts index 6be34c89..61ff71b3 100644 --- a/packages/data/src/typed-buffer/structs/index.ts +++ b/packages/data/src/typed-buffer/structs/index.ts @@ -1,3 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { getStructLayout } from "./get-struct-layout.js"; export * from "./struct-layout.js"; +export { wgslStructFields } from "./wgsl-struct-fields.js"; diff --git a/packages/data/src/typed-buffer/structs/wgsl-struct-fields.ts b/packages/data/src/typed-buffer/structs/wgsl-struct-fields.ts new file mode 100644 index 00000000..6cd0145d --- /dev/null +++ b/packages/data/src/typed-buffer/structs/wgsl-struct-fields.ts @@ -0,0 +1,57 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { type Schema } from "../../schema/index.js"; + +const primitiveWgslType = (schema: Schema): string | null => { + if (schema.type === "number") return "f32"; + if (schema.type === "integer") { + return schema.minimum !== undefined && schema.minimum >= 0 ? "u32" : "i32"; + } + return null; +}; + +const fieldWgslType = (schema: Schema): string | null => { + const prim = primitiveWgslType(schema); + if (prim) return prim; + if ( + schema.type === "array" && + schema.items !== undefined && + !Array.isArray(schema.items) && + schema.minItems === schema.maxItems && + schema.minItems !== undefined + ) { + const elemType = primitiveWgslType(schema.items); + if (!elemType) return null; + const n = schema.minItems; + const suffix = elemType === "f32" ? "f" : elemType === "i32" ? "i" : "u"; + if (n === 16 && suffix === "f") return "mat4x4f"; + if (n === 2 || n === 3 || n === 4) return `vec${n}${suffix}`; + } + return null; +}; + +/** + * Generates WGSL struct field declarations from a JSON Schema object type. + * + * Maps each property to the appropriate WGSL type (f32, vec3f, mat4x4f, etc.) + * using the same schema that drives host-side TypedBuffer layout — so the host + * struct and the WGSL struct are guaranteed to agree on field order and types. + * + * Usage: + * ```ts + * const source = ` + * struct MyUniforms { + * ${wgslStructFields(MyUniforms.schema)} + * }`; + * ``` + */ +export const wgslStructFields = (schema: Schema): string => { + if (schema.type !== "object" || !schema.properties) return ""; + return Object.entries(schema.properties) + .map(([name, fieldSchema]) => { + const type = fieldWgslType(fieldSchema); + if (!type) throw new Error(`Cannot map schema field "${name}" to a WGSL type`); + return ` ${name}: ${type},`; + }) + .join("\n"); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f71adc5d..de88c908 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,50 @@ importers: specifier: ^9.0.9 version: 9.0.9 + packages/data-graphics: + dependencies: + '@adobe/data': + specifier: workspace:* + version: link:../data + devDependencies: + '@webgpu/types': + specifier: ^0.1.61 + version: 0.1.61 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + + packages/data-graphics-samples: + dependencies: + '@adobe/data': + specifier: workspace:* + version: link:../data + '@adobe/data-graphics': + specifier: workspace:* + version: link:../data-graphics + '@adobe/data-lit': + specifier: workspace:* + version: link:../data-lit + lit: + specifier: ^3.3.1 + version: 3.3.1 + devDependencies: + '@webgpu/types': + specifier: ^0.1.61 + version: 0.1.61 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vite: + specifier: ^5.1.1 + version: 5.1.1(@types/node@25.6.0) + vite-plugin-checker: + specifier: ^0.12.0 + version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + packages/data-lit: dependencies: '@adobe/data': @@ -241,6 +285,9 @@ importers: vite: specifier: ^5.1.1 version: 5.1.1(@types/node@25.6.0) + vite-plugin-checker: + specifier: ^0.12.0 + version: 0.12.0(typescript@5.8.3)(vite@5.1.1) packages/data-persistence: dependencies: @@ -313,6 +360,9 @@ importers: vite: specifier: ^5.1.1 version: 5.1.1(@types/node@25.6.0) + vite-plugin-checker: + specifier: ^0.12.0 + version: 0.12.0(typescript@5.8.3)(vite@5.1.1) packages/data-react-pixie: dependencies: @@ -388,6 +438,9 @@ importers: vite: specifier: ^5.1.1 version: 5.1.1(@types/node@25.6.0) + vite-plugin-checker: + specifier: ^0.12.0 + version: 0.12.0(typescript@5.8.3)(vite@5.1.1) vite-plugin-solid: specifier: ^2.11.0 version: 2.11.0(solid-js@1.9.12)(vite@5.1.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7fd38ecc..07319e6c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,3 +11,5 @@ packages: - 'packages/data-p2p-tictactoe' - 'packages/data-solid' - 'packages/data-solid-dashboard' + - 'packages/data-graphics' + - 'packages/data-graphics-samples' From 97e57eadfa79d924964a3b7196d4238386997354 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 14 May 2026 23:25:43 -0700 Subject: [PATCH 002/109] feat(pbr): declarative model loading via Geometry + Model archetypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace imperative loadGltfModel() calls with a declarative ECS pattern. Users insert a Geometry entity with pbrModelUrl; the pbrModelLoader system fetches the GLTF, uploads to GPU, and writes pbrModelBounds when done. A Model entity references a Geometry via pbrGeometryRef and carries node transforms + visibility — renderers only draw primitives whose Geometry has at least one visible Model. Co-Authored-By: Claude Sonnet 4.6 --- .../metal-rough-spheres-element.ts | 38 ++++---- .../pbr-model-ibl/pbr-model-ibl-element.ts | 38 ++++---- .../pbr-model-ibl/pbr-model-ibl-service.ts | 4 +- .../samples/pbr-model/pbr-model-element.ts | 38 ++++---- .../samples/pbr-model/pbr-model-service.ts | 4 +- .../src/pbr/gltf/load-gltf-model.ts | 58 +++++------- packages/data-graphics/src/pbr/index.ts | 4 +- .../data-graphics/src/pbr/plugins/pbr-core.ts | 28 +----- .../src/pbr/plugins/pbr-direct.ts | 15 ++- .../data-graphics/src/pbr/plugins/pbr-ibl.ts | 15 ++- .../src/pbr/plugins/pbr-model-loader.ts | 93 +++++++++++++++++++ 11 files changed, 209 insertions(+), 126 deletions(-) create mode 100644 packages/data-graphics/src/pbr/plugins/pbr-model-loader.ts diff --git a/packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres-element.ts b/packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres-element.ts index 6fe9a59d..ac41bb0d 100644 --- a/packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres-element.ts +++ b/packages/data-graphics-samples/src/samples/metal-rough-spheres/metal-rough-spheres-element.ts @@ -2,8 +2,7 @@ import { LitElement, html, css } from "lit"; import { customElement, state } from "lit/decorators.js"; -import type { Vec3 } from "@adobe/data/math"; -import { loadGltfModel } from "@adobe/data-graphics"; +import type { Aabb } from "@adobe/data/math"; import { createPbrModelIblService } from "../pbr-model-ibl/pbr-model-ibl-service.js"; const tagName = "metal-rough-spheres"; @@ -56,7 +55,7 @@ export class MetalRoughSpheresElement extends LitElement { this.dragging = false; }; - override async firstUpdated() { + override firstUpdated() { const canvas = this.renderRoot.querySelector("canvas"); if (!canvas) return; canvas.addEventListener("pointerdown", this.onPointerDown); @@ -67,28 +66,29 @@ export class MetalRoughSpheresElement extends LitElement { this.service.transactions.setIblEnvironmentUrl(ENV_URL); this.service.transactions.setLight({ color: [0, 0, 0] }); - try { - const loaded = await loadGltfModel(this.service, MODEL_URL); - const center: Vec3 = [ - (loaded.boundsMin[0] + loaded.boundsMax[0]) / 2, - (loaded.boundsMin[1] + loaded.boundsMax[1]) / 2, - (loaded.boundsMin[2] + loaded.boundsMax[2]) / 2, - ]; + const geoId = this.service.transactions.insertGeometry({ pbrModelUrl: MODEL_URL }); + this.service.transactions.insertModel({ pbrGeometryRef: geoId }); + + const unsub = this.service.observe.entity(geoId)((values: unknown) => { + const bounds = (values as Record | null)?.pbrModelBounds as Aabb | undefined; + if (!bounds) return; + unsub(); const size = Math.max( - loaded.boundsMax[0] - loaded.boundsMin[0], - loaded.boundsMax[1] - loaded.boundsMin[1], - loaded.boundsMax[2] - loaded.boundsMin[2], + bounds.max[0] - bounds.min[0], + bounds.max[1] - bounds.min[1], + bounds.max[2] - bounds.min[2], ); this.service.transactions.setOrbit({ - center, + center: [ + (bounds.min[0] + bounds.max[0]) / 2, + (bounds.min[1] + bounds.max[1]) / 2, + (bounds.min[2] + bounds.max[2]) / 2, + ], radius: size * 1.2, height: 0, }); - this.status = `${loaded.primitiveCount} primitive(s) · IBL`; - } catch (e) { - console.error("MetalRoughSpheres load failed", e); - this.status = `error: ${(e as Error).message}`; - } + this.status = "IBL"; + }); } override render() { diff --git a/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-element.ts b/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-element.ts index 8008bfb9..a9a6f1b5 100644 --- a/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-element.ts +++ b/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-element.ts @@ -2,8 +2,7 @@ import { LitElement, html, css } from "lit"; import { customElement, state } from "lit/decorators.js"; -import type { Vec3 } from "@adobe/data/math"; -import { loadGltfModel } from "@adobe/data-graphics"; +import type { Aabb } from "@adobe/data/math"; import { createPbrModelIblService } from "./pbr-model-ibl-service.js"; const tagName = "pbr-model-ibl"; @@ -56,7 +55,7 @@ export class PbrModelIblElement extends LitElement { this.dragging = false; }; - override async firstUpdated() { + override firstUpdated() { const canvas = this.renderRoot.querySelector("canvas"); if (!canvas) return; canvas.addEventListener("pointerdown", this.onPointerDown); @@ -67,28 +66,29 @@ export class PbrModelIblElement extends LitElement { this.service.transactions.setIblEnvironmentUrl(ENV_URL); this.service.transactions.setLight({ color: [0.4, 0.4, 0.4] }); - try { - const loaded = await loadGltfModel(this.service, MODEL_URL); - const center: Vec3 = [ - (loaded.boundsMin[0] + loaded.boundsMax[0]) / 2, - (loaded.boundsMin[1] + loaded.boundsMax[1]) / 2, - (loaded.boundsMin[2] + loaded.boundsMax[2]) / 2, - ]; + const geoId = this.service.transactions.insertGeometry({ pbrModelUrl: MODEL_URL }); + this.service.transactions.insertModel({ pbrGeometryRef: geoId }); + + const unsub = this.service.observe.entity(geoId)((values: unknown) => { + const bounds = (values as Record | null)?.pbrModelBounds as Aabb | undefined; + if (!bounds) return; + unsub(); const size = Math.max( - loaded.boundsMax[0] - loaded.boundsMin[0], - loaded.boundsMax[1] - loaded.boundsMin[1], - loaded.boundsMax[2] - loaded.boundsMin[2], + bounds.max[0] - bounds.min[0], + bounds.max[1] - bounds.min[1], + bounds.max[2] - bounds.min[2], ); this.service.transactions.setOrbit({ - center, + center: [ + (bounds.min[0] + bounds.max[0]) / 2, + (bounds.min[1] + bounds.max[1]) / 2, + (bounds.min[2] + bounds.max[2]) / 2, + ], radius: size * 1.6, height: size * 0.25, }); - this.status = `${loaded.primitiveCount} primitive(s) · IBL`; - } catch (e) { - console.error("PBR-IBL model load failed", e); - this.status = `error: ${(e as Error).message}`; - } + this.status = "IBL"; + }); } override render() { diff --git a/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-service.ts b/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-service.ts index fc532ee4..f5e0a38e 100644 --- a/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-service.ts +++ b/packages/data-graphics-samples/src/samples/pbr-model-ibl/pbr-model-ibl-service.ts @@ -2,10 +2,10 @@ import { Database } from "@adobe/data/ecs"; import { F32, Vec3 } from "@adobe/data/math"; -import { pbrIbl } from "@adobe/data-graphics"; +import { pbrIbl, pbrModelLoader } from "@adobe/data-graphics"; export const pbrModelIblPlugin = Database.Plugin.create({ - extends: pbrIbl, + extends: Database.Plugin.combine(pbrIbl, pbrModelLoader), resources: { orbitCenter: { default: [0, 0, 0] as Vec3, transient: true }, orbitRadius: { default: 3 as F32, transient: true }, diff --git a/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-element.ts b/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-element.ts index 76e6eac0..2c65ad9a 100644 --- a/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-element.ts +++ b/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-element.ts @@ -2,8 +2,7 @@ import { LitElement, html, css } from "lit"; import { customElement, state } from "lit/decorators.js"; -import type { Vec3 } from "@adobe/data/math"; -import { loadGltfModel } from "@adobe/data-graphics"; +import type { Aabb } from "@adobe/data/math"; import { createPbrModelService } from "./pbr-model-service.js"; const tagName = "pbr-model"; @@ -28,33 +27,34 @@ export class PbrModelElement extends LitElement { private service = createPbrModelService(); @state() private status = "loading…"; - override async firstUpdated() { + override firstUpdated() { const canvas = this.renderRoot.querySelector("canvas"); if (!canvas) return; this.service.transactions.setCanvas(canvas); - try { - const loaded = await loadGltfModel(this.service, MODEL_URL); - const center: Vec3 = [ - (loaded.boundsMin[0] + loaded.boundsMax[0]) / 2, - (loaded.boundsMin[1] + loaded.boundsMax[1]) / 2, - (loaded.boundsMin[2] + loaded.boundsMax[2]) / 2, - ]; + const geoId = this.service.transactions.insertGeometry({ pbrModelUrl: MODEL_URL }); + this.service.transactions.insertModel({ pbrGeometryRef: geoId }); + + const unsub = this.service.observe.entity(geoId)((values: unknown) => { + const bounds = (values as Record | null)?.pbrModelBounds as Aabb | undefined; + if (!bounds) return; + unsub(); const size = Math.max( - loaded.boundsMax[0] - loaded.boundsMin[0], - loaded.boundsMax[1] - loaded.boundsMin[1], - loaded.boundsMax[2] - loaded.boundsMin[2], + bounds.max[0] - bounds.min[0], + bounds.max[1] - bounds.min[1], + bounds.max[2] - bounds.min[2], ); this.service.transactions.setOrbit({ - center, + center: [ + (bounds.min[0] + bounds.max[0]) / 2, + (bounds.min[1] + bounds.max[1]) / 2, + (bounds.min[2] + bounds.max[2]) / 2, + ], radius: size * 1.6, height: size * 0.25, }); - this.status = `${loaded.primitiveCount} primitive(s)`; - } catch (e) { - console.error("PBR model load failed", e); - this.status = `error: ${(e as Error).message}`; - } + this.status = "ready"; + }); } override render() { diff --git a/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-service.ts b/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-service.ts index 54d39732..5efdbbf1 100644 --- a/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-service.ts +++ b/packages/data-graphics-samples/src/samples/pbr-model/pbr-model-service.ts @@ -2,10 +2,10 @@ import { Database } from "@adobe/data/ecs"; import { F32, Vec3 } from "@adobe/data/math"; -import { pbrDirect } from "@adobe/data-graphics"; +import { pbrDirect, pbrModelLoader } from "@adobe/data-graphics"; export const pbrModelPlugin = Database.Plugin.create({ - extends: pbrDirect, + extends: Database.Plugin.combine(pbrDirect, pbrModelLoader), resources: { orbitCenter: { default: [0, 0, 0] as Vec3, transient: true }, orbitRadius: { default: 3 as F32, transient: true }, diff --git a/packages/data-graphics/src/pbr/gltf/load-gltf-model.ts b/packages/data-graphics/src/pbr/gltf/load-gltf-model.ts index 76614ef2..13532718 100644 --- a/packages/data-graphics/src/pbr/gltf/load-gltf-model.ts +++ b/packages/data-graphics/src/pbr/gltf/load-gltf-model.ts @@ -1,10 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import type { Vec3 } from "@adobe/data/math"; -import { graphics } from "../../plugins/graphics.js"; +import type { Aabb } from "@adobe/data/math"; import { createMaterialBindGroupLayout } from "../bind-group-layouts.js"; -import { pbrCore, type PbrPrimitiveInsert } from "../plugins/pbr-core.js"; import { readAccessor } from "./accessor-view.js"; import { buildMaterialBindGroup, type FallbackViews } from "./build-material-bind-group.js"; import { computeWorldMatrices } from "./compute-world-matrices.js"; @@ -12,24 +9,17 @@ import { createFallbackTextures, decodeAllImages } from "./decode-images.js"; import { packPrimitiveVertices } from "./pack-vertex-buffer.js"; import { parseGlb } from "./parse-glb.js"; -export interface LoadedGltfModel { - boundsMin: Vec3; - boundsMax: Vec3; - primitiveCount: number; +export interface GpuPrimitiveData { + pbrVertexBuffer: GPUBuffer; + pbrIndexBuffer: GPUBuffer; + pbrIndexCount: number; + pbrIndexFormat: GPUIndexFormat; + pbrMaterialBindGroup: GPUBindGroup; } -const loaderPlugin = Database.Plugin.combine(pbrCore, graphics); -type PbrDatabase = Database.Plugin.ToDatabase; - -function waitForDevice(db: PbrDatabase): Promise { - return new Promise(resolve => { - const unobserve = db.observe.resources.device(value => { - if (value) { - resolve(value); - queueMicrotask(() => unobserve?.()); - } - }); - }); +export interface LoadedGltfData { + primitives: GpuPrimitiveData[]; + bounds: Aabb; } function pickIndexFormat(raw: Uint16Array | Uint32Array | Uint8Array | Float32Array): { @@ -47,17 +37,15 @@ function pickIndexFormat(raw: Uint16Array | Uint32Array | Uint8Array | Float32Ar } /** - * Fetches and parses a GLB file at `url`, decodes its textures, builds GPU - * buffers and per-material bind groups, and inserts one PbrPrimitive entity - * per mesh primitive. Returns the model's world-space AABB so the caller can - * frame the camera. + * Fetches and parses a GLB file, decodes textures, and builds GPU buffers and + * per-material bind groups. Returns the raw data without touching the ECS + * database — the caller is responsible for inserting entities. */ -export async function loadGltfModel(db: PbrDatabase, url: string): Promise { +export async function loadGltfPrimitives(device: GPUDevice, url: string): Promise { const response = await fetch(url); if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`); const buffer = await response.arrayBuffer(); - const device = await waitForDevice(db); const { json, bin } = parseGlb(buffer); const materialLayout = createMaterialBindGroupLayout(device); @@ -73,10 +61,9 @@ export async function loadGltfModel(db: PbrDatabase, url: string): Promise modelMax[i]) modelMax[i] = packed.boundsMax[i]; + if (packed.boundsMin[i] < boundsMin[i]) boundsMin[i] = packed.boundsMin[i]; + if (packed.boundsMax[i] > boundsMax[i]) boundsMax[i] = packed.boundsMax[i]; } const vertexBuffer = device.createBuffer({ @@ -122,11 +109,8 @@ export async function loadGltfModel(db: PbrDatabase, url: string): Promise { @@ -69,19 +70,31 @@ export const pbrDirect = Database.Plugin.create({ renderPassEncoder.setPipeline(pipeline); renderPassEncoder.setBindGroup(0, sceneBindGroup); + const visibleGeo = new Set(); + for (const arch of db.store.queryArchetypes(["pbrGeometryRef", "visible"])) { + const refs = arch.columns.pbrGeometryRef; + const vis = arch.columns.visible; + for (let i = 0; i < arch.rowCount; i++) { + if (vis.get(i)) visibleGeo.add(refs.get(i) as number); + } + } + for (const archetype of db.store.queryArchetypes([ "pbrVertexBuffer", "pbrIndexBuffer", "pbrIndexCount", "pbrIndexFormat", "pbrMaterialBindGroup", + "pbrGeometryRef", ])) { const vbs = archetype.columns.pbrVertexBuffer; const ibs = archetype.columns.pbrIndexBuffer; const counts = archetype.columns.pbrIndexCount; const formats = archetype.columns.pbrIndexFormat; const groups = archetype.columns.pbrMaterialBindGroup; + const geoRefs = archetype.columns.pbrGeometryRef; for (let i = 0; i < archetype.rowCount; i++) { + if (!visibleGeo.has(geoRefs.get(i) as number)) continue; const vb = vbs.get(i); const ib = ibs.get(i); const count = counts.get(i); diff --git a/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts b/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts index b32b7cc8..f5f5578b 100644 --- a/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts +++ b/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts @@ -3,6 +3,7 @@ import { Database } from "@adobe/data/ecs"; import { Vec3 } from "@adobe/data/math"; import { defaultSceneUniforms } from "../../plugins/default-scene-uniforms.js"; +import { node } from "../../plugins/node.js"; import { createMaterialBindGroupLayout, createSceneBindGroupLayout } from "../bind-group-layouts.js"; import { buildIblResources } from "../ibl/build-ibl-resources.js"; import { parseHdr } from "../ibl/parse-hdr.js"; @@ -44,7 +45,7 @@ function createSkyboxBindGroupLayout(device: GPUDevice): GPUBindGroupLayout { * Mutually exclusive with `pbrDirect` — both iterate the same archetype. */ export const pbrIbl = Database.Plugin.create({ - extends: Database.Plugin.combine(pbrCore, defaultSceneUniforms), + extends: Database.Plugin.combine(pbrCore, defaultSceneUniforms, node), resources: { iblEnvironmentUrl: { default: null as string | null, transient: true }, iblEnvironmentMap: { default: null as GPUTexture | null, transient: true }, @@ -230,19 +231,31 @@ export const pbrIbl = Database.Plugin.create({ renderPassEncoder.setBindGroup(0, sceneBindGroup); renderPassEncoder.setBindGroup(2, iblBindGroup); + const visibleGeo = new Set(); + for (const arch of db.store.queryArchetypes(["pbrGeometryRef", "visible"])) { + const refs = arch.columns.pbrGeometryRef; + const vis = arch.columns.visible; + for (let i = 0; i < arch.rowCount; i++) { + if (vis.get(i)) visibleGeo.add(refs.get(i) as number); + } + } + for (const archetype of db.store.queryArchetypes([ "pbrVertexBuffer", "pbrIndexBuffer", "pbrIndexCount", "pbrIndexFormat", "pbrMaterialBindGroup", + "pbrGeometryRef", ])) { const vbs = archetype.columns.pbrVertexBuffer; const ibs = archetype.columns.pbrIndexBuffer; const counts = archetype.columns.pbrIndexCount; const formats = archetype.columns.pbrIndexFormat; const groups = archetype.columns.pbrMaterialBindGroup; + const geoRefs = archetype.columns.pbrGeometryRef; for (let i = 0; i < archetype.rowCount; i++) { + if (!visibleGeo.has(geoRefs.get(i) as number)) continue; const vb = vbs.get(i); const ib = ibs.get(i); const count = counts.get(i); diff --git a/packages/data-graphics/src/pbr/plugins/pbr-model-loader.ts b/packages/data-graphics/src/pbr/plugins/pbr-model-loader.ts new file mode 100644 index 00000000..aa161222 --- /dev/null +++ b/packages/data-graphics/src/pbr/plugins/pbr-model-loader.ts @@ -0,0 +1,93 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Database } from "@adobe/data/ecs"; +import type { Aabb, Quat, Vec3 } from "@adobe/data/math"; +import { graphics } from "../../plugins/graphics.js"; +import { node } from "../../plugins/node.js"; +import { loadGltfPrimitives, type GpuPrimitiveData } from "../gltf/load-gltf-model.js"; +import { pbrCore } from "./pbr-core.js"; + +interface LoadedArgs { + pbrGeometryRef: number; + bounds: Aabb; + primitives: GpuPrimitiveData[]; +} + +/** + * Declarative GLTF model loader. Provides two user-facing archetypes: + * + * - **Geometry**: insert with `{ pbrModelUrl }`. The loader system fetches, + * parses, and uploads the model. When done, `pbrModelBounds` is added to + * the entity (its presence signals that loading is complete). + * + * - **Model**: insert with `{ pbrGeometryRef, position?, rotation?, scale? }`. + * Presence of a visible Model is what causes its Geometry's primitives to + * be drawn each frame. + */ +export const pbrModelLoader = Database.Plugin.create({ + extends: Database.Plugin.combine(pbrCore, node, graphics), + components: { + pbrModelUrl: { default: "" as string }, + pbrModelBounds: { default: null as unknown as Aabb }, + }, + archetypes: { + Geometry: ["pbrModelUrl"], + Model: ["pbrGeometryRef", "position", "rotation", "scale", "visible"], + }, + transactions: { + insertGeometry(t, args: { pbrModelUrl: string }): number { + return t.archetypes.Geometry.insert({ pbrModelUrl: args.pbrModelUrl }); + }, + insertModel(t, args: { pbrGeometryRef: number; position?: Vec3; rotation?: Quat; scale?: Vec3 }): number { + return t.archetypes.Model.insert({ + pbrGeometryRef: args.pbrGeometryRef, + position: args.position ?? [0, 0, 0], + rotation: args.rotation ?? [0, 0, 0, 1], + scale: args.scale ?? [1, 1, 1], + visible: true, + }); + }, + pbrInsertLoadedPrimitives(t, args: LoadedArgs) { + for (const p of args.primitives) { + t.archetypes.PbrPrimitive.insert({ + ephemeral: true, + pbrGeometryRef: args.pbrGeometryRef, + ...p, + }); + } + t.update(args.pbrGeometryRef, { pbrModelBounds: args.bounds }); + }, + }, + systems: { + pbrModelLoadSystem: { + create: db => { + const inFlight = new Set(); + return () => { + const { device } = db.store.resources; + if (!device) return; + for (const arch of db.store.queryArchetypes(["pbrModelUrl"])) { + const ids = arch.columns.id; + const urls = arch.columns.pbrModelUrl; + for (let i = 0; i < arch.rowCount; i++) { + const id = ids.get(i) as number; + if (inFlight.has(id)) continue; + inFlight.add(id); + loadGltfPrimitives(device, urls.get(i) as string) + .then(({ primitives, bounds }) => { + db.transactions.pbrInsertLoadedPrimitives({ + pbrGeometryRef: id, + bounds, + primitives, + }); + }) + .catch(err => { + console.error("[pbrModelLoader] Failed to load model", urls.get(i), err); + }); + } + } + }; + }, + schedule: { during: ["preUpdate"] }, + }, + }, +}); From 68207361b5605a8dbaf171b49140c47a83f3e605 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 14 May 2026 23:56:05 -0700 Subject: [PATCH 003/109] feat(pbr): separate PbrMaterial from PbrPrimitive archetypes Split the combined GPU mesh+material entity into two: PbrMaterial holds the bind group, PbrPrimitive holds vertex/index buffers and a pbrMaterialRef pointing to its material. Renderers build a materialMap per frame and skip redundant setBindGroup calls by comparing bind group object identity. This is the foundation for material sorting (group draws by bind group to minimize GPU state changes) and future instanced rendering where materials and geometry vary independently across draw calls. Fix: use GPUBindGroup object reference as the redundancy sentinel instead of an integer ID; ephemeral entity IDs are negative starting at -1, which collided with the -1 integer sentinel and silently skipped setBindGroup on the first primitive every frame. Co-Authored-By: Claude Sonnet 4.6 --- .../data-graphics/src/pbr/plugins/pbr-core.ts | 4 ++- .../src/pbr/plugins/pbr-direct.ts | 33 ++++++++++++------- .../data-graphics/src/pbr/plugins/pbr-ibl.ts | 33 ++++++++++++------- .../src/pbr/plugins/pbr-model-loader.ts | 11 ++++++- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/packages/data-graphics/src/pbr/plugins/pbr-core.ts b/packages/data-graphics/src/pbr/plugins/pbr-core.ts index cd44a32d..abe9181a 100644 --- a/packages/data-graphics/src/pbr/plugins/pbr-core.ts +++ b/packages/data-graphics/src/pbr/plugins/pbr-core.ts @@ -21,8 +21,10 @@ export const pbrCore = Database.Plugin.create({ pbrIndexFormat: { default: "uint16" as GPUIndexFormat }, pbrMaterialBindGroup: { default: null as unknown as GPUBindGroup }, pbrGeometryRef: { default: 0 as number }, + pbrMaterialRef: { default: 0 as number }, }, archetypes: { - PbrPrimitive: ["ephemeral", "pbrVertexBuffer", "pbrIndexBuffer", "pbrIndexCount", "pbrIndexFormat", "pbrMaterialBindGroup", "pbrGeometryRef"], + PbrMaterial: ["ephemeral", "pbrMaterialBindGroup", "pbrGeometryRef"], + PbrPrimitive: ["ephemeral", "pbrVertexBuffer", "pbrIndexBuffer", "pbrIndexCount", "pbrIndexFormat", "pbrMaterialRef", "pbrGeometryRef"], }, }); diff --git a/packages/data-graphics/src/pbr/plugins/pbr-direct.ts b/packages/data-graphics/src/pbr/plugins/pbr-direct.ts index 87d84987..f3690264 100644 --- a/packages/data-graphics/src/pbr/plugins/pbr-direct.ts +++ b/packages/data-graphics/src/pbr/plugins/pbr-direct.ts @@ -79,32 +79,41 @@ export const pbrDirect = Database.Plugin.create({ } } + const materialMap = new Map(); + for (const arch of db.store.queryArchetypes(["pbrMaterialBindGroup"])) { + const ids = arch.columns.id; + const bgs = arch.columns.pbrMaterialBindGroup; + for (let i = 0; i < arch.rowCount; i++) { + materialMap.set(ids.get(i) as number, bgs.get(i)); + } + } + + let lastMat: GPUBindGroup | null = null; for (const archetype of db.store.queryArchetypes([ "pbrVertexBuffer", "pbrIndexBuffer", "pbrIndexCount", "pbrIndexFormat", - "pbrMaterialBindGroup", + "pbrMaterialRef", "pbrGeometryRef", ])) { const vbs = archetype.columns.pbrVertexBuffer; const ibs = archetype.columns.pbrIndexBuffer; const counts = archetype.columns.pbrIndexCount; const formats = archetype.columns.pbrIndexFormat; - const groups = archetype.columns.pbrMaterialBindGroup; + const matRefs = archetype.columns.pbrMaterialRef; const geoRefs = archetype.columns.pbrGeometryRef; for (let i = 0; i < archetype.rowCount; i++) { if (!visibleGeo.has(geoRefs.get(i) as number)) continue; - const vb = vbs.get(i); - const ib = ibs.get(i); - const count = counts.get(i); - const fmt = formats.get(i); - const mat = groups.get(i); - if (!vb || !ib || !count || !mat) continue; - renderPassEncoder.setVertexBuffer(0, vb); - renderPassEncoder.setIndexBuffer(ib, fmt); - renderPassEncoder.setBindGroup(1, mat); - renderPassEncoder.drawIndexed(count); + const mat = materialMap.get(matRefs.get(i) as number); + if (!mat) continue; + if (mat !== lastMat) { + renderPassEncoder.setBindGroup(1, mat); + lastMat = mat; + } + renderPassEncoder.setVertexBuffer(0, vbs.get(i)); + renderPassEncoder.setIndexBuffer(ibs.get(i), formats.get(i)); + renderPassEncoder.drawIndexed(counts.get(i)); } } }; diff --git a/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts b/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts index f5f5578b..cfa9580f 100644 --- a/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts +++ b/packages/data-graphics/src/pbr/plugins/pbr-ibl.ts @@ -240,32 +240,41 @@ export const pbrIbl = Database.Plugin.create({ } } + const materialMap = new Map(); + for (const arch of db.store.queryArchetypes(["pbrMaterialBindGroup"])) { + const ids = arch.columns.id; + const bgs = arch.columns.pbrMaterialBindGroup; + for (let i = 0; i < arch.rowCount; i++) { + materialMap.set(ids.get(i) as number, bgs.get(i)); + } + } + + let lastMat: GPUBindGroup | null = null; for (const archetype of db.store.queryArchetypes([ "pbrVertexBuffer", "pbrIndexBuffer", "pbrIndexCount", "pbrIndexFormat", - "pbrMaterialBindGroup", + "pbrMaterialRef", "pbrGeometryRef", ])) { const vbs = archetype.columns.pbrVertexBuffer; const ibs = archetype.columns.pbrIndexBuffer; const counts = archetype.columns.pbrIndexCount; const formats = archetype.columns.pbrIndexFormat; - const groups = archetype.columns.pbrMaterialBindGroup; + const matRefs = archetype.columns.pbrMaterialRef; const geoRefs = archetype.columns.pbrGeometryRef; for (let i = 0; i < archetype.rowCount; i++) { if (!visibleGeo.has(geoRefs.get(i) as number)) continue; - const vb = vbs.get(i); - const ib = ibs.get(i); - const count = counts.get(i); - const fmt = formats.get(i); - const mat = groups.get(i); - if (!vb || !ib || !count || !mat) continue; - renderPassEncoder.setVertexBuffer(0, vb); - renderPassEncoder.setIndexBuffer(ib, fmt); - renderPassEncoder.setBindGroup(1, mat); - renderPassEncoder.drawIndexed(count); + const mat = materialMap.get(matRefs.get(i) as number); + if (!mat) continue; + if (mat !== lastMat) { + renderPassEncoder.setBindGroup(1, mat); + lastMat = mat; + } + renderPassEncoder.setVertexBuffer(0, vbs.get(i)); + renderPassEncoder.setIndexBuffer(ibs.get(i), formats.get(i)); + renderPassEncoder.drawIndexed(counts.get(i)); } } }; diff --git a/packages/data-graphics/src/pbr/plugins/pbr-model-loader.ts b/packages/data-graphics/src/pbr/plugins/pbr-model-loader.ts index aa161222..7c2315ee 100644 --- a/packages/data-graphics/src/pbr/plugins/pbr-model-loader.ts +++ b/packages/data-graphics/src/pbr/plugins/pbr-model-loader.ts @@ -49,10 +49,19 @@ export const pbrModelLoader = Database.Plugin.create({ }, pbrInsertLoadedPrimitives(t, args: LoadedArgs) { for (const p of args.primitives) { + const materialId = t.archetypes.PbrMaterial.insert({ + ephemeral: true, + pbrMaterialBindGroup: p.pbrMaterialBindGroup, + pbrGeometryRef: args.pbrGeometryRef, + }); t.archetypes.PbrPrimitive.insert({ ephemeral: true, pbrGeometryRef: args.pbrGeometryRef, - ...p, + pbrMaterialRef: materialId, + pbrVertexBuffer: p.pbrVertexBuffer, + pbrIndexBuffer: p.pbrIndexBuffer, + pbrIndexCount: p.pbrIndexCount, + pbrIndexFormat: p.pbrIndexFormat, }); } t.update(args.pbrGeometryRef, { pbrModelBounds: args.bounds }); From 844cfac9e8d0b0f49ddb2c2d3165e246caa390ef Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 15 May 2026 00:27:16 -0700 Subject: [PATCH 004/109] feat(graphics): instanced rendering via per-Model TRS transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both IBL and direct renderers now issue drawIndexed with instanceCount derived from the ECS Model entities. Each visible Model contributes a mat4x4 (built from position/rotation/scale) to a per-Geometry GPU storage buffer uploaded each frame. The vertex shader reads the matrix via @builtin(instance_index) and applies a cofactor normal transform. Adds pbr-ibl-instanced sample: 4×4 grid of DamagedHelmet sharing one Geometry entity, rendered with a single drawIndexed per primitive. Co-Authored-By: Claude Sonnet 4.6 --- .../sample-container-element.ts | 4 +- .../pbr-ibl-instanced-element.ts | 116 ++++++++++++++++++ .../pbr-ibl-instanced-service.ts | 64 ++++++++++ .../pbr-ibl-instanced/pbr-ibl-instanced.ts | 9 ++ .../src/pbr/plugins/direct-shader.wgsl.ts | 21 +++- .../src/pbr/plugins/ibl-shader.wgsl.ts | 21 +++- .../src/pbr/plugins/pbr-direct.ts | 73 +++++++++-- .../data-graphics/src/pbr/plugins/pbr-ibl.ts | 74 +++++++++-- 8 files changed, 354 insertions(+), 28 deletions(-) create mode 100644 packages/data-graphics-samples/src/samples/pbr-ibl-instanced/pbr-ibl-instanced-element.ts create mode 100644 packages/data-graphics-samples/src/samples/pbr-ibl-instanced/pbr-ibl-instanced-service.ts create mode 100644 packages/data-graphics-samples/src/samples/pbr-ibl-instanced/pbr-ibl-instanced.ts diff --git a/packages/data-graphics-samples/src/sample-container/sample-container-element.ts b/packages/data-graphics-samples/src/sample-container/sample-container-element.ts index b2263c18..d0868bfb 100644 --- a/packages/data-graphics-samples/src/sample-container/sample-container-element.ts +++ b/packages/data-graphics-samples/src/sample-container/sample-container-element.ts @@ -6,6 +6,7 @@ import { HelloTriangle } from "../samples/hello-triangle/hello-triangle.js"; import { PbrModel } from "../samples/pbr-model/pbr-model.js"; import { PbrModelIbl } from "../samples/pbr-model-ibl/pbr-model-ibl.js"; import { MetalRoughSpheres } from "../samples/metal-rough-spheres/metal-rough-spheres.js"; +import { PbrIblInstanced } from "../samples/pbr-ibl-instanced/pbr-ibl-instanced.js"; const tagName = "sample-container"; @@ -30,7 +31,7 @@ export class SampleContainerElement extends LitElement { } override render() { - const samples = ["hello-triangle", "pbr-model", "pbr-model-ibl", "metal-rough-spheres"]; + const samples = ["hello-triangle", "pbr-model", "pbr-model-ibl", "metal-rough-spheres", "pbr-ibl-instanced"]; return html`