From 70bceda0a69877424585565f5dd4d34413074e94 Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 15:42:11 -0300 Subject: [PATCH 01/21] docs: add WebGL to WebGPU migration design and implementation plan Co-Authored-By: Claude Opus 4.5 --- ...-02-04-webgl-to-webgpu-migration-design.md | 101 ++ ...26-02-04-webgl-to-webgpu-migration-plan.md | 906 ++++++++++++++++++ 2 files changed, 1007 insertions(+) create mode 100644 docs/plans/2026-02-04-webgl-to-webgpu-migration-design.md create mode 100644 docs/plans/2026-02-04-webgl-to-webgpu-migration-plan.md diff --git a/docs/plans/2026-02-04-webgl-to-webgpu-migration-design.md b/docs/plans/2026-02-04-webgl-to-webgpu-migration-design.md new file mode 100644 index 00000000..5dd756a4 --- /dev/null +++ b/docs/plans/2026-02-04-webgl-to-webgpu-migration-design.md @@ -0,0 +1,101 @@ +# WebGL to WebGPU Migration Design + +## Goal + +Migrate the rendering pipeline from WebGLRenderer to WebGPURenderer and convert all GLSL shaders to TSL (Three Shading Language) for performance improvements. The project already uses Three.js 0.180.0 and R3F 9.0.0-rc.6, both of which support WebGPU. + +## Approach + +Incremental migration: swap the renderer first (GLSL compat layer keeps existing shaders working), then convert shaders to TSL one by one. + +--- + +## Phase 0: Renderer Swap + +Switch from WebGLRenderer to WebGPURenderer. All existing GLSL shaders continue working via the compatibility layer. + +### Files changed + +1. **`src/components/scene/index.tsx`** — Replace `gl` prop with async WebGPURenderer factory +2. **`src/hooks/use-webgl.ts`** — Rename to `use-gpu.ts`, detect WebGPU via `navigator.gpu`, fall back to WebGL +3. **`src/utils/double-fbo.ts`** — `WebGLRenderTarget` -> `RenderTarget` +4. **`src/components/postprocessing/renderer.tsx`** — `WebGLRenderTarget` -> `RenderTarget`, update depth texture creation +5. **`src/components/arcade-screen/render-texture.tsx`** — `WebGLRenderTarget` -> `RenderTarget` +6. **`src/components/contact/contact-canvas.tsx`** — Verify offscreen canvas compatibility with WebGPU + +--- + +## Phase 1: Shader Migration (GLSL -> TSL) + +Convert each shader material from GLSL files + ShaderMaterial to TSL node materials. For each shader: +- Delete `.glsl` vertex/fragment files +- Rewrite TypeScript factory to use TSL node functions +- Replace `ShaderMaterial`/`RawShaderMaterial` with `NodeMaterial` +- Update uniform access from `material.uniforms.x.value` to TSL uniform `.value` + +### Tier 1 — Simple + +| # | Shader | Files | Notes | +|---|--------|-------|-------| +| 1 | material-screen | `src/shaders/material-screen/{vertex.glsl,fragment.glsl,index.ts}` | Texture + reveal | +| 2 | material-not-found | `src/shaders/material-not-found/{vertex.glsl,fragment.glsl,index.ts}` | Time distortion | +| 3 | material-steam | `src/shaders/material-steam/{vertex.glsl,fragment.glsl,index.ts}` | Basic effect | +| 4 | routing-element | `src/components/routing-element/{vert.glsl,frag.glsl,routing-element.tsx}` | Resolution + opacity | +| 5 | sparkles | `src/components/sparkles/{vert.glsl,frag.glsl,index.tsx}` | Particle shader | + +### Tier 2 — Medium + +| # | Shader | Files | Notes | +|---|--------|-------|-------| +| 6 | material-net | `src/shaders/material-net/{vertex.glsl,fragment.glsl,index.ts}` | Data texture displacement | +| 7 | material-postprocessing | `src/shaders/material-postprocessing/{vertex.glsl,fragment.glsl,index.ts}` | Bloom, vignette, color | +| 8 | material-characters | `src/shaders/material-characters/{vertex.glsl,fragment.glsl,index.ts}` | Texture + fade | +| 9 | material-solid-reveal | `src/shaders/material-solid-reveal/{vertex.glsl,fragment.glsl,index.ts}` | RawShaderMaterial + GLSL3 | + +### Tier 3 — Complex + +| # | Shader | Files | Notes | +|---|--------|-------|-------| +| 10 | material-flow | `src/shaders/material-flow/{vertex.glsl,fragment.glsl,index.ts}` | Feedback simulation, RawShaderMaterial + GLSL3 | +| 11 | material-global-shader | `src/shaders/material-global-shader/{vertex.glsl,fragment.glsl,index.tsx}` | Glass, godray, lighting, fog, video, matcap, clouds, daylight. Conditional compilation via defines. | +| 12 | instanced-skinned-mesh | `src/components/characters/instanced-skinned-mesh/{instanced-skinned-mesh.ts,index.tsx}` | ShaderChunk patching for instanced skeletal animation | + +### Tier 4 — Inline shaders + +| # | Shader | Files | Notes | +|---|--------|-------|-------| +| 13 | CRT mesh | `src/components/doom-js/crt-mesh.tsx` | Inline GLSL: barrel distortion, chromatic aberration, scanlines | +| 14 | Loading scene | `src/components/loading/loading-scene/index.tsx` | Inline reveal shaders | + +### Utility shaders + +- `src/shaders/utils/basic-light.glsl` -> TSL helper function +- `src/shaders/utils/value-remap.glsl` -> TSL helper function + +--- + +## Phase 2: Component & Cleanup + +### Component updates +- **`src/components/postprocessing/post-processing.tsx`** — Uniform updates to TSL nodes +- **`src/components/loading/loading-scene/index.tsx`** — FBO usage updates +- **`src/components/godrays/index.tsx`** — Uniform manipulation to TSL nodes +- **`src/components/arcade-screen/index.tsx`** — Material creation updates +- **`src/components/map/index.tsx`** — Material swapping updates + +### Config cleanup +- **`next.config.ts`** — Remove GLSL webpack/turbopack loader rules + +### Dependency cleanup +- Remove `raw-loader`, `glslify-loader` from devDependencies +- Remove `glsl-noise` (replace with TSL noise functions like `mx_noise_float()`) + +--- + +## Risks + +1. **WebGPURenderer GLSL compat layer** may not handle all GLSL features (especially `RawShaderMaterial` with `GLSL3`). Test Phase 0 thoroughly before proceeding. +2. **Instanced skinned mesh** uses `ShaderChunk` patching which has no direct TSL equivalent. May need a custom TSL node or a different instancing approach. +3. **Offscreen canvas** (`@react-three/offscreen`) WebGPU support is untested. +4. **Browser support** — WebGPU requires Chrome 113+, Edge 113+, Firefox behind flag, no Safari. Need a fallback strategy or accept limited browser support. +5. **`glsl-noise`** dependency used in flow shader. TSL has built-in noise via MaterialX nodes (`mx_noise_float`, `mx_fractal_noise_float`), but output may differ visually. diff --git a/docs/plans/2026-02-04-webgl-to-webgpu-migration-plan.md b/docs/plans/2026-02-04-webgl-to-webgpu-migration-plan.md new file mode 100644 index 00000000..5fd929c9 --- /dev/null +++ b/docs/plans/2026-02-04-webgl-to-webgpu-migration-plan.md @@ -0,0 +1,906 @@ +# WebGL to WebGPU Migration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Migrate the rendering pipeline from WebGLRenderer to WebGPURenderer and convert all GLSL shaders to TSL for performance gains. + +**Architecture:** Incremental migration — swap the renderer first (GLSL compat layer keeps existing shaders working), then convert each shader material from GLSL to TSL one at a time. Each task is independently testable and committable. + +**Tech Stack:** Three.js 0.180.0, React Three Fiber 9.0.0-rc.6, TSL (Three Shading Language), WebGPURenderer, Next.js 15.6.0-canary.60 + +--- + +## Task 1: Swap WebGLRenderer to WebGPURenderer + +**Files:** +- Modify: `src/components/scene/index.tsx:1-210` + +**Step 1: Update imports** + +Add the WebGPURenderer import and update the existing Three.js import: + +```tsx +import * as THREE from "three" +import WebGPURenderer from "three/webgpu" +``` + +**Step 2: Change the `gl` prop on Canvas** + +Replace the current `gl` prop (lines 155-160): + +```tsx +gl={{ + antialias: false, + alpha: false, + outputColorSpace: THREE.SRGBColorSpace, + toneMapping: THREE.NoToneMapping +}} +``` + +With an async renderer factory: + +```tsx +gl={async (canvas) => { + const renderer = new WebGPURenderer({ + canvas, + antialias: false, + alpha: false, + }) + await renderer.init() + renderer.outputColorSpace = THREE.SRGBColorSpace + renderer.toneMapping = THREE.NoToneMapping + return renderer +}} +``` + +**Step 3: Verify the app starts and renders** + +Run: `pnpm dev` +Expected: The site loads and renders the 3D scene. All existing GLSL shaders work via the WebGPURenderer's GLSL compatibility layer. + +**Step 4: Commit** + +```bash +git add src/components/scene/index.tsx +git commit -m "feat: swap WebGLRenderer to WebGPURenderer" +``` + +--- + +## Task 2: Update use-webgl hook to use-gpu + +**Files:** +- Modify: `src/hooks/use-webgl.ts` → rename to `src/hooks/use-gpu.ts` +- Modify: `src/hooks/use-handle-contact.ts:6,14` + +**Step 1: Rename and rewrite the hook** + +Delete `src/hooks/use-webgl.ts` and create `src/hooks/use-gpu.ts`: + +```ts +import { useEffect, useState } from "react" + +export const useGpu = () => { + const [gpuEnabled, setGpuEnabled] = useState(true) + + useEffect(() => { + const hasWebGPU = typeof navigator !== "undefined" && "gpu" in navigator + setGpuEnabled(hasWebGPU) + }, []) + + return gpuEnabled +} +``` + +**Step 2: Update the consumer** + +In `src/hooks/use-handle-contact.ts`, change: + +```ts +import { useWebgl } from "@/hooks/use-webgl" +``` +to: +```ts +import { useGpu } from "@/hooks/use-gpu" +``` + +And change line 14: +```ts +const webglEnabled = useWebgl() +``` +to: +```ts +const webglEnabled = useGpu() +``` + +**Step 3: Verify** + +Run: `pnpm dev` +Navigate to the contact button — it should still work the same way (opening the 3D contact form on desktop, routing to /contact on mobile or when GPU is unavailable). + +**Step 4: Commit** + +```bash +git add src/hooks/use-gpu.ts src/hooks/use-handle-contact.ts +git rm src/hooks/use-webgl.ts +git commit -m "refactor: rename use-webgl to use-gpu with WebGPU detection" +``` + +--- + +## Task 3: Replace WebGLRenderTarget with RenderTarget in double-fbo utility + +**Files:** +- Modify: `src/utils/double-fbo.ts` + +**Step 1: Update the type and constructor** + +In Three.js 0.180.0, `WebGLRenderTarget` is still available but `RenderTarget` is the base class that works with both WebGL and WebGPU. However, since WebGPURenderer still accepts `WebGLRenderTarget`, keep using `WebGLRenderTarget` for now — it's an alias. The actual migration is just ensuring type compatibility. + +Actually, check Three.js 0.180.0 — `WebGLRenderTarget` remains the class name even when used with WebGPURenderer. The WebGPU backend handles it internally. **No changes needed to this file for Phase 0.** + +Skip this task — `WebGLRenderTarget` works with WebGPURenderer as-is in Three.js 0.180.0. + +--- + +## Task 4: Verify postprocessing renderer works with WebGPU + +**Files:** +- Verify: `src/components/postprocessing/renderer.tsx` + +No code changes needed. `WebGLRenderTarget` is compatible with WebGPURenderer. The `gl.setRenderTarget()` and `gl.render()` calls work identically. + +**Step 1: Manual verification** + +Run: `pnpm dev` +Expected: Post-processing (bloom, vignette, color correction) renders correctly. + +--- + +## Task 5: Verify arcade screen render-texture works with WebGPU + +**Files:** +- Verify: `src/components/arcade-screen/render-texture.tsx` +- Verify: `src/components/arcade-screen/index.tsx` + +No code changes needed. `WebGLRenderTarget` works with WebGPURenderer. + +**Step 1: Manual verification** + +Run: `pnpm dev` +Navigate to the arcade/lab section. The arcade screen should render correctly with its render-to-texture pipeline. + +--- + +## Task 6: Create TSL utility helpers (value-remap, basic-light) + +**Files:** +- Create: `src/shaders/utils/value-remap.ts` +- Create: `src/shaders/utils/basic-light.ts` +- Keep: `src/shaders/utils/value-remap.glsl` (still used by unconverted shaders) +- Keep: `src/shaders/utils/basic-light.glsl` (still used by unconverted shaders) + +**Step 1: Create value-remap TSL utility** + +Create `src/shaders/utils/value-remap.ts`: + +```ts +import { clamp, div, float, mul, sub, add, Fn, vec2, vec3 } from "three/tsl" +import type { ShaderNodeObject, Node } from "three/tsl" + +export const valueRemap = Fn( + ([value, inMin, inMax, outMin, outMax]: [ + ShaderNodeObject, + ShaderNodeObject, + ShaderNodeObject, + ShaderNodeObject, + ShaderNodeObject + ]) => { + return add(outMin, mul(div(sub(value, inMin), sub(inMax, inMin)), sub(outMax, outMin))) + } +) + +export const valueRemapNormalized = Fn( + ([value, inMin, inMax]: [ + ShaderNodeObject, + ShaderNodeObject, + ShaderNodeObject + ]) => { + return div(sub(value, inMin), sub(inMax, inMin)) + } +) +``` + +**Step 2: Create basic-light TSL utility** + +Create `src/shaders/utils/basic-light.ts`: + +```ts +import { dot, normalize, clamp, pow, mul, add, float, Fn } from "three/tsl" +import type { ShaderNodeObject, Node } from "three/tsl" +import { valueRemap } from "./value-remap" + +export const basicLight = Fn( + ([normal, lightDir, intensity]: [ + ShaderNodeObject, + ShaderNodeObject, + ShaderNodeObject + ]) => { + const lightFactor = dot(lightDir, normalize(normal)) + const remapped = valueRemap(lightFactor, float(0.2), float(1.0), float(0.1), float(1.0)) + const clamped = clamp(remapped, 0.0, 1.0) + const powered = pow(clamped, float(2.0)) + return add(mul(powered, intensity), float(1.0)) + } +) +``` + +**Step 3: Commit** + +```bash +git add src/shaders/utils/value-remap.ts src/shaders/utils/basic-light.ts +git commit -m "feat: add TSL utility helpers for value-remap and basic-light" +``` + +--- + +## Task 7: Migrate material-steam (Tier 1 — simplest shader) + +**Files:** +- Modify: `src/shaders/material-steam/index.ts` +- Delete: `src/shaders/material-steam/vertex.glsl` +- Delete: `src/shaders/material-steam/fragment.glsl` + +**Step 1: Rewrite the material factory to TSL** + +Replace `src/shaders/material-steam/index.ts`: + +```ts +import { + Fn, + uniform, + uv, + texture, + float, + vec2, + vec3, + vec4, + smoothstep, + mod, + floor, + mul, + sub, + add, + clamp, + discard, + sin, + cos, + mat2, + positionLocal, + modelWorldMatrix, + cameraProjectionMatrix, + modelViewMatrix, + assign, + varying, + varyingProperty +} from "three/tsl" +import { DoubleSide, NodeMaterial, Texture } from "three" + +export const createSteamMaterial = () => { + const uTime = uniform(0) + const uNoise = uniform(null as Texture | null) + + const material = new NodeMaterial() + material.transparent = true + material.side = DoubleSide + + // Vertex: twist + offset based on noise + material.vertexNode = Fn(() => { + const vUv = uv() + const noiseOffset = texture(uNoise, vec2(float(0.25), mul(uTime, 0.005))).r + const offset = vec2(mul(noiseOffset, mul(pow(vUv.y, float(1.2)), 0.035)), float(0.0)) + + const pos = positionLocal.toVar() + pos.x.addAssign(offset.x) + pos.z.addAssign(offset.x) + + const twist = texture(uNoise, vec2(float(0.5), sub(mul(vUv.y, 0.2), mul(uTime, 0.005)))).r + const angle = mul(twist, 8.0) + const s = sin(angle) + const c = cos(angle) + const newX = sub(mul(pos.x, c), mul(pos.z, s)) + const newZ = add(mul(pos.x, s), mul(pos.z, c)) + pos.x.assign(newX) + pos.z.assign(newZ) + + return cameraProjectionMatrix.mul(modelViewMatrix).mul(vec4(pos, 1.0)) + })() + + // Fragment + material.colorNode = Fn(() => { + const vUv = uv() + const steamUv = vec2(mul(vUv.x, 0.5), sub(mul(vUv.y, 0.3), mul(uTime, 0.015))) + const steam = smoothstep(float(0.45), float(1.0), texture(uNoise, steamUv).r) + + const edgeFadeX = mul( + smoothstep(float(0.0), float(0.15), vUv.x), + sub(float(1.0), smoothstep(float(0.85), float(1.0), vUv.x)) + ) + const edgeFadeY = mul( + smoothstep(float(0.0), float(0.15), vUv.y), + sub(float(1.0), smoothstep(float(0.85), float(1.0), vUv.y)) + ) + + const fadedSteam = mul(steam, mul(edgeFadeX, edgeFadeY)) + + const pattern = mod( + add(floor(mul(screenCoordinate.x, 0.5)), floor(mul(screenCoordinate.y, 0.5))), + float(2.0) + ) + + const gradient = clamp(sub(add(float(0.2), sub(float(1.0), mul(float(1.25), vUv.y))), float(0.0)), 0.0, 1.0) + + const alpha = mul(fadedSteam, mul(pattern, gradient)) + + discard(alpha.lessThan(0.001)) + + return vec4(vec3(0.92, 0.78, 0.62), alpha) + })() + + return { material, uniforms: { uTime, uNoise } } +} +``` + +> **Note:** The exact TSL API calls above are approximate — TSL's API in Three.js 0.180.0 may vary. The implementing engineer should reference the Three.js TSL examples at `node_modules/three/examples/jsm/` and the TSL docs. The key pattern is: use `NodeMaterial`, set `vertexNode` / `colorNode` / `opacityNode`, and use TSL functions instead of GLSL. + +**Step 2: Update all consumers of createSteamMaterial** + +Search for `createSteamMaterial` usage and update them to use the new `{ material, uniforms }` return shape. The old pattern `material.uniforms.uTime.value = x` becomes `uniforms.uTime.value = x`. + +**Step 3: Delete GLSL files** + +```bash +git rm src/shaders/material-steam/vertex.glsl src/shaders/material-steam/fragment.glsl +``` + +**Step 4: Verify** + +Run: `pnpm dev` +Check that the steam effect renders correctly in the scene. + +**Step 5: Commit** + +```bash +git add src/shaders/material-steam/ +git commit -m "feat: migrate material-steam from GLSL to TSL" +``` + +--- + +## Task 8: Migrate material-not-found (Tier 1) + +**Files:** +- Modify: `src/shaders/material-not-found/index.ts` +- Delete: `src/shaders/material-not-found/vertex.glsl` +- Delete: `src/shaders/material-not-found/fragment.glsl` + +**Step 1: Rewrite to TSL NodeMaterial** + +The not-found shader does: UV manipulation (shake, rotation), texture sampling with bleeding, scanlines, noise, grayscale tint. Convert all of these to TSL node operations. + +Key conversions: +- `texture2D(tDiffuse, uv)` → `texture(tDiffuseUniform, uvNode)` +- `sin/cos/fract/dot` → `sin/cos/fract/dot` from `three/tsl` +- `gl_FragColor` → return from `colorNode` Fn +- `varying vec2 vUv` → `uv()` built-in + +The factory should return `{ material, uniforms: { tDiffuse, uTime, resolution } }`. + +**Step 2: Update consumers** + +In `src/shaders/material-global-shader/index.tsx` and `src/components/map/index.tsx`, search for `createNotFoundMaterial` and update to use the new return shape. + +**Step 3: Delete GLSL files, verify, commit** + +```bash +git rm src/shaders/material-not-found/vertex.glsl src/shaders/material-not-found/fragment.glsl +git add src/shaders/material-not-found/index.ts +git commit -m "feat: migrate material-not-found from GLSL to TSL" +``` + +--- + +## Task 9: Migrate material-screen (Tier 1) + +**Files:** +- Modify: `src/shaders/material-screen/index.ts` +- Delete: `src/shaders/material-screen/vertex.glsl` +- Delete: `src/shaders/material-screen/fragment.glsl` + +This is the most complex Tier 1 shader — it has CRT-like effects (curve remap, interference, scanlines, noise, vignette, reveal animation, pixelation). Take care to replicate each effect faithfully. + +**Step 1: Rewrite to TSL** + +Key conversions: +- The vertex shader uses `#include` chunks (skinning, logdepthbuf) — in TSL, these are handled automatically by the NodeMaterial pipeline +- The fragment has complex UV manipulation — convert each function (`curveRemapUV`, `random`, `peak`) to TSL `Fn` helpers +- Conditional logic (`if/else`) needs to use TSL's `select()` or `cond()` functions +- The `uFlip` conditional block with pixelation needs careful conversion + +**Step 2: Update consumers** + +In `src/components/arcade-screen/index.tsx`, `createScreenMaterial()` is called and uniforms are accessed directly. Update to the new return shape. + +**Step 3: Delete GLSL files, verify, commit** + +```bash +git rm src/shaders/material-screen/vertex.glsl src/shaders/material-screen/fragment.glsl +git add src/shaders/material-screen/index.ts +git commit -m "feat: migrate material-screen from GLSL to TSL" +``` + +--- + +## Task 10: Migrate routing-element shader (Tier 1) + +**Files:** +- Modify: `src/components/routing-element/routing-element.tsx:32-53` +- Delete: `src/components/routing-element/vert.glsl` +- Delete: `src/components/routing-element/frag.glsl` + +**Step 1: Create inline TSL material in routing-element.tsx** + +Replace the `ShaderMaterial` creation in `useMemo` (line 33) with a `NodeMaterial`. The shader does: border detection using UV derivatives (`dFdx`/`dFdy`), diagonal line pattern, padding/border logic. + +Key conversions: +- `dFdx(vUv)` / `dFdy(vUv)` → TSL `dFdx()` / `dFdy()` +- The checkerboard/diagonal pattern needs TSL math +- Uniforms become TSL `uniform()` nodes + +**Step 2: Update uniform access** + +Lines 257-258 access `routingMaterial.uniforms.borderPadding.value` and `routingMaterial.uniforms.opacity.value`. Change to TSL uniform `.value` assignment. + +**Step 3: Delete GLSL files, verify, commit** + +```bash +git rm src/components/routing-element/vert.glsl src/components/routing-element/frag.glsl +git add src/components/routing-element/routing-element.tsx +git commit -m "feat: migrate routing-element shader from GLSL to TSL" +``` + +--- + +## Task 11: Migrate sparkles shader (Tier 1) + +**Files:** +- Modify: `src/components/sparkles/index.tsx` +- Delete: `src/components/sparkles/vert.glsl` +- Delete: `src/components/sparkles/frag.glsl` + +**Step 1: Replace shaderMaterial from drei with TSL NodeMaterial** + +The current implementation uses `shaderMaterial()` from `@react-three/drei` with `extend()`. Replace with a custom `NodeMaterial` class. + +Key conversions: +- The vertex shader does point-based rendering with custom size, jitter animation, and pulsing opacity — use TSL's `pointSizeNode` and custom position transforms +- The fragment shader uses `vColor`, `vOpacity`, and `fadeFactor` — set `colorNode` and `opacityNode` on the NodeMaterial +- `#include ` and `#include ` are handled automatically by NodeMaterial + +**Step 2: Update SparklesImpl integration** + +The `` from drei may need a different approach since we're replacing the material. Check if `SparklesImpl` accepts a custom material child. + +**Step 3: Delete GLSL files, verify, commit** + +```bash +git rm src/components/sparkles/vert.glsl src/components/sparkles/frag.glsl +git add src/components/sparkles/index.tsx +git commit -m "feat: migrate sparkles shader from GLSL to TSL" +``` + +--- + +## Task 12: Migrate material-net (Tier 2) + +**Files:** +- Modify: `src/shaders/material-net/index.ts` +- Delete: `src/shaders/material-net/vertex.glsl` +- Delete: `src/shaders/material-net/fragment.glsl` + +**Step 1: Rewrite to TSL** + +The net shader does vertex displacement from a DataTexture based on frame animation. Key conversions: +- Custom attribute `uv1` → use `attribute()` from TSL or `bufferAttribute()` +- `texture2D(tDisplacement, vDisplacementUv).xzy` → TSL texture sampling with swizzle +- Vertex position offset → `positionLocal` manipulation in `vertexNode` +- Fragment is simple: `texture2D(map, vUv)` → `texture(mapUniform, uv())` + +**Step 2: Update consumers, delete GLSL, verify, commit** + +```bash +git rm src/shaders/material-net/vertex.glsl src/shaders/material-net/fragment.glsl +git add src/shaders/material-net/index.ts +git commit -m "feat: migrate material-net from GLSL to TSL" +``` + +--- + +## Task 13: Migrate material-postprocessing (Tier 2) + +**Files:** +- Modify: `src/shaders/material-postprocessing/index.ts` +- Modify: `src/components/postprocessing/post-processing.tsx` (uniform access updates) +- Delete: `src/shaders/material-postprocessing/vertex.glsl` +- Delete: `src/shaders/material-postprocessing/fragment.glsl` + +**Step 1: Rewrite to TSL** + +This is a full-screen post-processing shader with: bloom (Vogel disk sampling), vignette, ACES tone mapping, contrast/brightness/gamma/exposure, scanlines, noise. This is complex but well-structured. + +Key conversions: +- The bloom loop (`for (int i = 1; i < SAMPLE_COUNT; i++)`) needs TSL's `Loop()` construct +- ACES tone mapping matrices → TSL `mat3()` and matrix multiplication +- `gl_FragCoord` → `screenCoordinate` or `screenUV` from TSL +- `#include ` → handled by NodeMaterial output + +**Step 2: Update post-processing.tsx** + +Lines 113-158 access uniforms via `material.uniforms.uContrast.value` etc. Change all to TSL uniform `.value` access on the returned uniforms object. + +**Step 3: Delete GLSL, verify, commit** + +```bash +git rm src/shaders/material-postprocessing/vertex.glsl src/shaders/material-postprocessing/fragment.glsl +git add src/shaders/material-postprocessing/index.ts src/components/postprocessing/post-processing.tsx +git commit -m "feat: migrate material-postprocessing from GLSL to TSL" +``` + +--- + +## Task 14: Migrate material-characters (Tier 2) + +**Files:** +- Modify: `src/shaders/material-characters/index.ts` +- Delete: `src/shaders/material-characters/vertex.glsl` +- Delete: `src/shaders/material-characters/fragment.glsl` + +**Step 1: Rewrite to TSL** + +This shader has: multi-map support via defines (`USE_MULTI_MAP`, `MULTI_MAP_COUNT`), instanced lighting with texture lookups, skinning/batching/morphing via `#include` chunks, gamma correction, and point lighting. + +Key considerations: +- The `#include` chunks for batching/skinning/morphing are handled by the `InstancedBatchedSkinnedMesh` class which patches `ShaderChunk`. When this shader is converted to TSL, the instanced-skinned-mesh system (Task 18) must also be converted, since TSL NodeMaterials don't use ShaderChunk. +- **Alternative:** Keep this shader as GLSL until Task 18 (instanced-skinned-mesh migration), then convert both together. + +**Decision: Defer this task to after Task 18.** The character material depends heavily on the instanced skinning system. + +--- + +## Task 15: Migrate material-solid-reveal (Tier 2) + +**Files:** +- Modify: `src/shaders/material-solid-reveal/index.ts` +- Delete: `src/shaders/material-solid-reveal/vertex.glsl` +- Delete: `src/shaders/material-solid-reveal/fragment.glsl` + +**Step 1: Rewrite to TSL** + +Currently uses `RawShaderMaterial` with `GLSL3`. Uses `glsl-noise` for 3D/4D classic Perlin noise. Key conversions: +- `#pragma glslify: cnoise3 = require(glsl-noise/classic/3d)` → TSL's `mx_noise_float()` or `mx_perlin_noise_float()` +- VoxelData struct → compute in a TSL `Fn` +- `worldToUv()` helper → TSL camera projection math +- `discard` → TSL `discard(condition)` + +**Note:** The visual output of TSL's MaterialX noise may differ from glsl-noise's classic Perlin noise. Visual comparison is required. + +**Step 2: Update loading-scene consumer, delete GLSL, verify, commit** + +```bash +git rm src/shaders/material-solid-reveal/vertex.glsl src/shaders/material-solid-reveal/fragment.glsl +git add src/shaders/material-solid-reveal/index.ts +git commit -m "feat: migrate material-solid-reveal from GLSL to TSL" +``` + +--- + +## Task 16: Migrate material-flow (Tier 3) + +**Files:** +- Modify: `src/shaders/material-flow/index.ts` +- Delete: `src/shaders/material-flow/vertex.glsl` +- Delete: `src/shaders/material-flow/fragment.glsl` + +**Step 1: Rewrite to TSL** + +Currently uses `RawShaderMaterial` with `GLSL3`. This is a feedback simulation shader. Key conversions: +- `textureSize(uFeedbackTexture, 0)` → TSL `textureSize()` or compute size from uniform +- `textureLod(uFeedbackTexture, uv, 0.0)` → TSL `textureSampleLevel()` or `texture().level(0)` +- `#pragma glslify: cnoise2 = require(glsl-noise/classic/2d)` → TSL noise +- The feedback loop logic (sampling neighbors, checking growth) needs careful TSL conversion +- `gl_Position = vec4(position, 1.0)` for fullscreen quad → TSL vertex position + +**Step 2: Update loading-scene consumer, delete GLSL, verify, commit** + +```bash +git rm src/shaders/material-flow/vertex.glsl src/shaders/material-flow/fragment.glsl +git add src/shaders/material-flow/index.ts +git commit -m "feat: migrate material-flow from GLSL to TSL" +``` + +--- + +## Task 17: Migrate material-global-shader (Tier 3 — largest shader) + +**Files:** +- Modify: `src/shaders/material-global-shader/index.tsx` +- Delete: `src/shaders/material-global-shader/vertex.glsl` +- Delete: `src/shaders/material-global-shader/fragment.glsl` + +**Step 1: Plan the conversion** + +This is the most complex shader with conditional compilation via defines: `GLASS`, `GODRAY`, `LIGHT`, `BASKETBALL`, `FOG`, `VIDEO`, `MATCAP`, `CLOUDS`, `DAYLIGHT`, `IS_LOBO_MARINO`, `USE_MAP`, `IS_TRANSPARENT`, `USE_ALPHA_MAP`, `USE_EMISSIVE`, `USE_EMISSIVEMAP`. + +In TSL, conditional compilation becomes runtime branching with `cond()` / `select()`, or you build up the node graph conditionally in TypeScript: + +```ts +if (defines.GLASS) { + // add glass nodes to the graph +} +``` + +This is actually cleaner than GLSL `#ifdef`. + +**Step 2: Rewrite to TSL** + +Key conversions: +- Lightmap, AO map, emissive, fog, glass reflection, godray, matcap → each becomes a conditional node graph addition +- The Zustand store tracking (`useCustomShaderMaterial`) needs updating since `NodeMaterial` has a different API than `ShaderMaterial` +- `customProgramCacheKey` → check if NodeMaterial supports this +- `#pragma glslify: valueRemap` → use the TSL `valueRemap` from Task 6 +- `#pragma glslify: basicLight` → use the TSL `basicLight` from Task 6 + +**Step 3: Update all consumers** + +The global shader is used in `src/components/map/index.tsx` and elsewhere. All `material.uniforms.x.value` access must be updated. + +**Step 4: Delete GLSL, verify, commit** + +```bash +git rm src/shaders/material-global-shader/vertex.glsl src/shaders/material-global-shader/fragment.glsl +git add src/shaders/material-global-shader/index.tsx +git commit -m "feat: migrate material-global-shader from GLSL to TSL" +``` + +--- + +## Task 18: Migrate instanced-skinned-mesh (Tier 3 — most architecturally complex) + +**Files:** +- Modify: `src/components/characters/instanced-skinned-mesh/instanced-skinned-mesh.ts` +- Modify: `src/components/characters/instanced-skinned-mesh/index.tsx` + +**Step 1: Understand the current approach** + +The current system patches `THREE.ShaderChunk.skinning_pars_vertex` and `THREE.ShaderChunk.skinning_vertex` with custom GLSL that: +1. Reads bone matrices from a `boneTexture` DataTexture +2. Reads keyframe indices from a `batchingKeyframeTexture` DataTexture +3. Applies skeletal animation per-instance using texture lookups +4. Supports morph targets via `morphDataTexture` +5. Uses `onBeforeCompile` to inject uniforms and defines + +**Step 2: Convert to TSL approach** + +In TSL, the equivalent approach is: +- Use TSL's `storage()` or `texture()` nodes to read the bone/keyframe textures +- Override `vertexNode` on the material to apply the custom skinning +- Remove the `ShaderChunk` patching entirely +- Remove the `onBeforeCompile` callback — use `NodeMaterial` directly + +This is the highest-risk task. The implementing engineer should: +1. Study Three.js TSL skinning examples +2. Check if `BatchedMesh` already works with `NodeMaterial` in r180 +3. Consider if the bone texture approach can be replaced with TSL's built-in instancing + +**Step 3: Verify character animations** + +Run: `pnpm dev` +Check all character animations render and play correctly. Test multiple characters on screen simultaneously. + +**Step 4: Commit** + +```bash +git add src/components/characters/instanced-skinned-mesh/ +git commit -m "feat: migrate instanced-skinned-mesh from ShaderChunk patching to TSL" +``` + +--- + +## Task 19: Now migrate material-characters (Tier 2, deferred from Task 14) + +**Files:** +- Modify: `src/shaders/material-characters/index.ts` +- Delete: `src/shaders/material-characters/vertex.glsl` +- Delete: `src/shaders/material-characters/fragment.glsl` + +Now that the instanced skinning system uses TSL (Task 18), convert the character material. + +**Step 1: Rewrite to TSL NodeMaterial** + +The multi-map support (`USE_MULTI_MAP` with `MULTI_MAP_COUNT`) becomes runtime TypeScript logic building up the node graph. The lighting calculations use the TSL `basicLight` helper. + +**Step 2: Delete GLSL, verify character rendering, commit** + +```bash +git rm src/shaders/material-characters/vertex.glsl src/shaders/material-characters/fragment.glsl +git add src/shaders/material-characters/index.ts +git commit -m "feat: migrate material-characters from GLSL to TSL" +``` + +--- + +## Task 20: Migrate CRT mesh inline shaders (Tier 4) + +**Files:** +- Modify: `src/components/doom-js/crt-mesh.tsx` + +**Step 1: Convert inline GLSL to TSL** + +Replace the `vertexShader` and `fragmentShader` string literals with TSL node graph. Replace `` JSX with a `NodeMaterial` instance. + +Key conversions: +- Barrel distortion → TSL math +- Chromatic aberration (sampling texture at 3 UV offsets) → 3 `texture()` calls with swizzle +- Scanlines, flicker, vignette, noise, phosphor glow → TSL math +- The material is created in JSX (``) — change to `` + +**Step 2: Verify, commit** + +```bash +git add src/components/doom-js/crt-mesh.tsx +git commit -m "feat: migrate CRT mesh inline shaders from GLSL to TSL" +``` + +--- + +## Task 21: Migrate loading scene inline shaders (Tier 4) + +**Files:** +- Modify: `src/components/loading/loading-scene/index.tsx` + +**Step 1: Convert inline GLSL to TSL** + +The loading scene has an inline `ShaderMaterial` for the wireframe lines (lines 160-212) with vertex + fragment shaders. Convert to a `NodeMaterial`. + +Key conversions: +- Vertex: world position → TSL `positionWorld` +- Fragment: reveal based on world Z position, uniform color/opacity + +**Step 2: Update the flow rendering pipeline** + +The `renderFlow` function (lines 311-347) uses `gl.setRenderTarget()` — this continues to work with WebGPURenderer, no changes needed there. + +**Step 3: Verify loading screen, commit** + +```bash +git add src/components/loading/loading-scene/index.tsx +git commit -m "feat: migrate loading scene inline shaders from GLSL to TSL" +``` + +--- + +## Task 22: Remove GLSL utility shaders + +**Files:** +- Delete: `src/shaders/utils/basic-light.glsl` +- Delete: `src/shaders/utils/value-remap.glsl` + +**Step 1: Verify no remaining GLSL imports** + +Search the codebase for any remaining `.glsl` imports: + +```bash +grep -r "\.glsl" src/ --include="*.ts" --include="*.tsx" +``` + +Expected: No results. All GLSL files should be deleted by now. + +**Step 2: Delete and commit** + +```bash +git rm src/shaders/utils/basic-light.glsl src/shaders/utils/value-remap.glsl +git commit -m "chore: remove GLSL utility shaders (replaced by TSL)" +``` + +--- + +## Task 23: Remove GLSL build configuration and dependencies + +**Files:** +- Modify: `next.config.ts:6-13,41-48` +- Modify: `package.json` (remove devDependencies) + +**Step 1: Remove webpack GLSL loader rules** + +In `next.config.ts`, remove the turbopack rules (lines 6-13): + +```ts +turbopack: { + rules: { + "*.{glsl,vert,frag,vs,fs}": { + loaders: ["raw-loader", "glslify-loader"], + as: "*.js" + } + } +}, +``` + +And remove the webpack rule (lines 41-48): + +```ts +webpack: (config) => { + config.module.rules.push({ + test: /\.(glsl|vs|fs|vert|frag)$/, + use: ["raw-loader", "glslify-loader"] + }) + + return config +}, +``` + +**Step 2: Remove dependencies** + +```bash +pnpm remove raw-loader glslify-loader glsl-noise +``` + +**Step 3: Verify build** + +```bash +pnpm build +``` + +Expected: Build succeeds with no GLSL-related errors. + +**Step 4: Commit** + +```bash +git add next.config.ts package.json pnpm-lock.yaml +git commit -m "chore: remove GLSL loader config and dependencies" +``` + +--- + +## Task 24: Final verification and cleanup + +**Step 1: Full build verification** + +```bash +pnpm build +``` + +**Step 2: Visual regression check** + +Run `pnpm dev` and manually verify every scene: +- Home page: characters, sparkles, godrays, fog, lighting +- Lab: arcade screen, CRT effect, render-to-texture +- Services: glass materials, matcap reflections +- 404: not-found material on TV, CCTV render target +- Loading screen: flow simulation, solid reveal, wireframe lines +- Contact: offscreen canvas animation +- Basketball: lighting, physics +- Post-processing: bloom, vignette, color correction across all scenes + +**Step 3: Performance comparison** + +Use Chrome DevTools Performance tab to compare: +- Frame time before (WebGL) vs after (WebGPU) +- GPU utilization +- Draw call counts + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "feat: complete WebGL to WebGPU migration" +``` From 65f4ad44ffad9cb9aa9757c764a034c0f1177373 Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 15:49:21 -0300 Subject: [PATCH 02/21] feat: swap WebGLRenderer to WebGPURenderer Use R3F v9's async gl prop to initialize WebGPURenderer. Existing GLSL shaders continue working via the compatibility layer. Co-Authored-By: Claude Opus 4.5 --- src/components/scene/index.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/scene/index.tsx b/src/components/scene/index.tsx index d4b9673f..b7c2e5c5 100644 --- a/src/components/scene/index.tsx +++ b/src/components/scene/index.tsx @@ -4,6 +4,7 @@ import { Canvas } from "@react-three/fiber" import dynamic from "next/dynamic" import { Suspense, useEffect, useRef, useState } from "react" import * as THREE from "three" +import { WebGPURenderer } from "three/webgpu" import ErrorBoundary from "@/components/basketball/error-boundary" import { CameraController } from "@/components/camera/camera-controller" @@ -152,11 +153,16 @@ export const Scene = () => { e.preventDefault() } }} - gl={{ - antialias: false, - alpha: false, - outputColorSpace: THREE.SRGBColorSpace, - toneMapping: THREE.NoToneMapping + gl={async (defaultProps) => { + const renderer = new WebGPURenderer({ + canvas: defaultProps.canvas as HTMLCanvasElement, + antialias: false, + alpha: false + }) + await renderer.init() + renderer.outputColorSpace = THREE.SRGBColorSpace + renderer.toneMapping = THREE.NoToneMapping + return renderer }} camera={{ fov: 60 }} className={cn( From 2679a6839f54c8d917483610e61a2094f212eec1 Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 15:49:58 -0300 Subject: [PATCH 03/21] refactor: rename use-webgl to use-gpu with WebGPU detection Co-Authored-By: Claude Opus 4.5 --- src/hooks/use-gpu.ts | 12 ++++++++++++ src/hooks/use-handle-contact.ts | 4 ++-- src/hooks/use-webgl.ts | 14 -------------- 3 files changed, 14 insertions(+), 16 deletions(-) create mode 100644 src/hooks/use-gpu.ts delete mode 100644 src/hooks/use-webgl.ts diff --git a/src/hooks/use-gpu.ts b/src/hooks/use-gpu.ts new file mode 100644 index 00000000..36c82c36 --- /dev/null +++ b/src/hooks/use-gpu.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react" + +export const useGpu = () => { + const [gpuEnabled, setGpuEnabled] = useState(true) + + useEffect(() => { + const hasWebGPU = typeof navigator !== "undefined" && "gpu" in navigator + setGpuEnabled(hasWebGPU) + }, []) + + return gpuEnabled +} diff --git a/src/hooks/use-handle-contact.ts b/src/hooks/use-handle-contact.ts index 8326e169..29195d7a 100644 --- a/src/hooks/use-handle-contact.ts +++ b/src/hooks/use-handle-contact.ts @@ -3,7 +3,7 @@ import { useCallback, useRef } from "react" import { useContactStore } from "@/components/contact/contact-store" import { useAppLoadingStore } from "@/components/loading/app-loading-handler" -import { useWebgl } from "@/hooks/use-webgl" +import { useGpu } from "@/hooks/use-gpu" export const useHandleContactButton = () => { const setIsContactOpen = useContactStore((state) => state.setIsContactOpen) @@ -11,7 +11,7 @@ export const useHandleContactButton = () => { const isAnimating = useContactStore((state) => state.isAnimating) const canRunMainApp = useAppLoadingStore((state) => state.canRunMainApp) const router = useRouter() - const webglEnabled = useWebgl() + const webglEnabled = useGpu() const clickTimeoutRef = useRef(null) const handleClick = useCallback(() => { diff --git a/src/hooks/use-webgl.ts b/src/hooks/use-webgl.ts deleted file mode 100644 index 177a94cd..00000000 --- a/src/hooks/use-webgl.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect } from "react" -import { useState } from "react" -import { WebGL } from "three/examples/jsm/Addons.js" - -export const useWebgl = () => { - const [webglEnabled, setWebglEnabled] = useState(true) - - useEffect(() => { - const webgl = WebGL.isWebGL2Available() - setWebglEnabled(webgl) - }, []) - - return webglEnabled -} From 7c9ec3b3201277f519799084e2c732b8c3d8ed3c Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 15:51:22 -0300 Subject: [PATCH 04/21] feat: add TSL utility helpers for value-remap and basic-light Co-Authored-By: Claude Opus 4.5 --- src/shaders/utils/basic-light.ts | 18 ++++++++++++++++++ src/shaders/utils/value-remap.ts | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/shaders/utils/basic-light.ts create mode 100644 src/shaders/utils/value-remap.ts diff --git a/src/shaders/utils/basic-light.ts b/src/shaders/utils/basic-light.ts new file mode 100644 index 00000000..8f305875 --- /dev/null +++ b/src/shaders/utils/basic-light.ts @@ -0,0 +1,18 @@ +import { add, clamp, dot, float, Fn, mul, normalize, pow } from "three/tsl" +import type { NodeRepresentation } from "three/tsl" + +import { valueRemap } from "./value-remap" + +export const basicLight = /* @__PURE__ */ Fn( + ([normal, lightDir, intensity]: [ + NodeRepresentation, + NodeRepresentation, + NodeRepresentation + ]) => { + const lightFactor = dot(lightDir, normalize(normal)) + const remapped = valueRemap(lightFactor, float(0.2), float(1.0), float(0.1), float(1.0)) + const clamped = clamp(remapped, 0.0, 1.0) + const powered = pow(clamped, float(2.0)) + return add(mul(powered, intensity), float(1.0)) + } +) diff --git a/src/shaders/utils/value-remap.ts b/src/shaders/utils/value-remap.ts new file mode 100644 index 00000000..e5cf8c25 --- /dev/null +++ b/src/shaders/utils/value-remap.ts @@ -0,0 +1,24 @@ +import { add, div, Fn, mul, sub } from "three/tsl" +import type { NodeRepresentation } from "three/tsl" + +export const valueRemap = /* @__PURE__ */ Fn( + ([value, inMin, inMax, outMin, outMax]: [ + NodeRepresentation, + NodeRepresentation, + NodeRepresentation, + NodeRepresentation, + NodeRepresentation + ]) => { + return add(outMin, mul(div(sub(value, inMin), sub(inMax, inMin)), sub(outMax, outMin))) + } +) + +export const valueRemapNormalized = /* @__PURE__ */ Fn( + ([value, inMin, inMax]: [ + NodeRepresentation, + NodeRepresentation, + NodeRepresentation + ]) => { + return div(sub(value, inMin), sub(inMax, inMin)) + } +) From 0f5e3adab27ff3981a552dde18db08240566ab9a Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 16:00:51 -0300 Subject: [PATCH 05/21] feat: migrate material-steam from GLSL to TSL Replace ShaderMaterial + GLSL with NodeMaterial + TSL node graph. Vertex shader uses positionNode for noise-based offset and twist. Fragment uses colorNode/opacityNode for steam pattern with edge fades, checkerboard, and gradient. Consumer updated to new return shape. Co-Authored-By: Claude Opus 4.5 --- src/components/arcade-board/coffee-steam.tsx | 8 +- src/shaders/material-steam/index.ts | 115 ++++++++++++++++--- 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/src/components/arcade-board/coffee-steam.tsx b/src/components/arcade-board/coffee-steam.tsx index 48f7842e..e74b57e5 100644 --- a/src/components/arcade-board/coffee-steam.tsx +++ b/src/components/arcade-board/coffee-steam.tsx @@ -11,14 +11,14 @@ export const CoffeeSteam = () => { noise.wrapT = RepeatWrapping noise.wrapS = RepeatWrapping - const material = useMemo(() => createSteamMaterial(), []) + const { material, uniforms } = useMemo(() => createSteamMaterial(), []) useEffect(() => { - material.uniforms.uNoise.value = noise - }, [noise, material]) + uniforms.uNoise.value = noise + }, [noise, uniforms]) useFrameCallback((_, __, elapsedTime) => { - material.uniforms.uTime.value = elapsedTime + uniforms.uTime.value = elapsedTime }) return ( diff --git a/src/shaders/material-steam/index.ts b/src/shaders/material-steam/index.ts index 7be66a9e..d0005672 100644 --- a/src/shaders/material-steam/index.ts +++ b/src/shaders/material-steam/index.ts @@ -1,16 +1,99 @@ -import { ShaderMaterial } from "three" - -import fragmentShader from "./fragment.glsl" -import vertexShader from "./vertex.glsl" - -export const createSteamMaterial = () => - new ShaderMaterial({ - transparent: true, - side: 2, - uniforms: { - uTime: { value: 0 }, - uNoise: { value: null } - }, - fragmentShader, - vertexShader - }) +import { + Fn, + float, + vec2, + vec3, + uniform, + uv, + texture, + smoothstep, + mod, + floor, + clamp, + sin, + cos, + pow, + positionLocal, + screenCoordinate +} from "three/tsl" +import { DoubleSide, Texture } from "three" +import { NodeMaterial } from "three/webgpu" + +export const createSteamMaterial = () => { + const uTime = uniform(0) + const uNoiseTex = texture(new Texture()) + + const material = new NodeMaterial() + material.transparent = true + material.side = DoubleSide + + // Vertex: noise-based offset + twist + material.positionNode = Fn(() => { + const vUv = uv() + const pos = positionLocal.toVar() + + // Noise-based offset + const noiseUv1 = vec2(float(0.25), uTime.mul(0.005)) + const noiseVal = uNoiseTex.uv(noiseUv1).r + const offsetAmount = noiseVal.mul(pow(vUv.y, float(1.2))).mul(0.035) + + pos.x.addAssign(offsetAmount) + pos.z.addAssign(offsetAmount) + + // Noise-based twist + const noiseUv2 = vec2(float(0.5), vUv.y.mul(0.2).sub(uTime.mul(0.005))) + const twist = uNoiseTex.uv(noiseUv2).r + const angle = twist.mul(8.0) + const s = sin(angle) + const c = cos(angle) + + const oldX = pos.x.toVar() + const oldZ = pos.z.toVar() + pos.x.assign(oldX.mul(c).sub(oldZ.mul(s))) + pos.z.assign(oldX.mul(s).add(oldZ.mul(c))) + + return pos + })() + + // Fragment: constant color + material.colorNode = vec3(0.92, 0.78, 0.62) + + // Fragment: steam alpha with edge fades, checkerboard, gradient + material.opacityNode = Fn(() => { + const vUv = uv() + + // Steam from noise + const steamUv = vec2(vUv.x.mul(0.5), vUv.y.mul(0.3).sub(uTime.mul(0.015))) + const steam = smoothstep(float(0.45), float(1.0), uNoiseTex.uv(steamUv).r) + + // Edge fades + const edgeFadeX = smoothstep(float(0.0), float(0.15), vUv.x) + .mul(float(1.0).sub(smoothstep(float(0.85), float(1.0), vUv.x))) + const edgeFadeY = smoothstep(float(0.0), float(0.15), vUv.y) + .mul(float(1.0).sub(smoothstep(float(0.85), float(1.0), vUv.y))) + + const fadedSteam = steam.mul(edgeFadeX).mul(edgeFadeY) + + // Checkerboard pattern from screen coordinates + const pattern = mod( + floor(screenCoordinate.x.mul(0.5)).add(floor(screenCoordinate.y.mul(0.5))), + float(2.0) + ) + + // Gradient + const gradient = clamp( + float(1.2).sub(float(1.25).mul(vUv.y)), + 0.0, + 1.0 + ) + + const alpha = fadedSteam.mul(pattern).mul(gradient) + + // Discard near-zero alpha fragments + alpha.lessThan(0.001).discard() + + return alpha + })() + + return { material, uniforms: { uTime, uNoise: uNoiseTex } } +} From db885ffabec6cc23d73c88fe01332b7e3ca1483d Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 16:06:05 -0300 Subject: [PATCH 06/21] feat: migrate material-not-found from GLSL to TSL Replace ShaderMaterial + GLSL with NodeMaterial + TSL for the TV screen CRT effect. Convert pseudo-random, shake, texture bleeding, scanlines, noise, and grayscale tint to TSL node operations. Store uTime uniform reference in mesh store for clean frame-loop updates (removes @ts-ignore). Co-Authored-By: Claude Opus 4.5 --- src/components/map/index.tsx | 16 +-- src/components/map/use-frame-loop.ts | 6 +- src/hooks/use-mesh.ts | 5 +- src/shaders/material-not-found/fragment.glsl | 54 ---------- src/shaders/material-not-found/index.ts | 107 ++++++++++++++++--- src/shaders/material-not-found/vertex.glsl | 5 - 6 files changed, 106 insertions(+), 87 deletions(-) delete mode 100644 src/shaders/material-not-found/fragment.glsl delete mode 100644 src/shaders/material-not-found/vertex.glsl diff --git a/src/components/map/index.tsx b/src/components/map/index.tsx index df190b7a..55b52b0f 100644 --- a/src/components/map/index.tsx +++ b/src/components/map/index.tsx @@ -113,16 +113,20 @@ export const Map = memo(() => { ) => { if (child.name === "SM_TvScreen_4" && "isMesh" in child) { const meshChild = child as Mesh - useMesh.setState({ cctv: { screen: meshChild } }) - const texture = cctvConfig.renderTarget.read.texture - - const diffuseUniform = { value: texture } + const { material: notFoundMat, uniforms: notFoundUniforms } = + createNotFoundMaterial() + notFoundUniforms.tDiffuse.value = + cctvConfig.renderTarget.read.texture cctvConfig.renderTarget.onSwap(() => { - diffuseUniform.value = cctvConfig.renderTarget.read.texture + notFoundUniforms.tDiffuse.value = + cctvConfig.renderTarget.read.texture }) - meshChild.material = createNotFoundMaterial(diffuseUniform) + meshChild.material = notFoundMat + useMesh.setState({ + cctv: { screen: meshChild, uTime: notFoundUniforms.uTime } + }) return } diff --git a/src/components/map/use-frame-loop.ts b/src/components/map/use-frame-loop.ts index 7f5d29e5..9888f197 100644 --- a/src/components/map/use-frame-loop.ts +++ b/src/components/map/use-frame-loop.ts @@ -15,9 +15,9 @@ export const useFrameLoop = () => { material.uniforms.fadeFactor.value = fadeFactor.current.get() }) - if (useMesh.getState().cctv?.screen?.material) { - // @ts-ignore - useMesh.getState().cctv.screen.material.uniforms.uTime.value += delta + const cctvUTime = useMesh.getState().cctv?.uTime + if (cctvUTime) { + cctvUTime.value += delta } }) } diff --git a/src/hooks/use-mesh.ts b/src/hooks/use-mesh.ts index 4c47c010..d610c9f6 100644 --- a/src/hooks/use-mesh.ts +++ b/src/hooks/use-mesh.ts @@ -38,7 +38,7 @@ export interface MeshStore { weather: weather services: services cars: (Mesh | null)[] - cctv: { screen: Mesh | null } + cctv: { screen: Mesh | null; uTime: { value: number } | null } } export const useMesh = create()(() => ({ @@ -69,6 +69,7 @@ export const useMesh = create()(() => ({ }, cars: [], cctv: { - screen: null + screen: null, + uTime: null } })) diff --git a/src/shaders/material-not-found/fragment.glsl b/src/shaders/material-not-found/fragment.glsl deleted file mode 100644 index 0b5ca55f..00000000 --- a/src/shaders/material-not-found/fragment.glsl +++ /dev/null @@ -1,54 +0,0 @@ -uniform sampler2D tDiffuse; -uniform float uTime; -uniform vec2 resolution; - -varying vec2 vUv; - -float random(vec2 st) { - return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453); -} - -vec2 shakeOffset(float time, float intensity) { - vec2 shake = vec2( - random(vec2(time * 0.3)) * 2.0 - 1.0, - random(vec2(time * 0.2)) * 2.0 - 1.0 - ); - - float shakeBurst = step(0.58, random(vec2(floor(time * 0.5)))); - return shake * intensity * shakeBurst; -} - -void main() { - float shakeIntensity = 0.0005; - vec2 shake = shakeOffset(uTime, shakeIntensity); - - vec2 uv2 = vUv + shake; - uv2.x = 1.0 - uv2.x; - uv2 -= 0.5; - float cosR = -1.0; - float sinR = 0.0; - vec2 shiftedUv = vec2( - uv2.x * cosR - uv2.y * sinR, - uv2.x * sinR + uv2.y * cosR - ); - shiftedUv += 0.5; - - vec4 color = texture2D(tDiffuse, shiftedUv); - - vec4 colorBleedUp = texture2D(tDiffuse, shiftedUv + vec2(0.0, 0.001)); - vec4 colorBleedDown = texture2D(tDiffuse, shiftedUv - vec2(0.0, 0.001)); - color += (colorBleedUp + colorBleedDown) * 0.5; - - float scanline = sin(vUv.y * resolution.y * 0.45 - uTime * 15.0); - color.rgb += color.rgb * scanline * 0.35; - - float noise = random(vUv + uTime * 5.0); - color.rgb += noise * 0.15; - - color.rgb += sin(vUv.y * 10.0 + uTime * 10.0) * 0.02; - - float grayscale = dot(color.rgb, vec3(0.299, 0.587, 0.114)); - - vec3 tint = vec3(0.898, 0.898, 0.898) * 3.0; - gl_FragColor = vec4(vec3(grayscale) * tint, color.a); -} diff --git a/src/shaders/material-not-found/index.ts b/src/shaders/material-not-found/index.ts index c169c8ee..bb25d743 100644 --- a/src/shaders/material-not-found/index.ts +++ b/src/shaders/material-not-found/index.ts @@ -1,17 +1,90 @@ -import { ShaderMaterial, Texture } from "three" -import { FrontSide, Vector2 } from "three" - -import fragmentShader from "./fragment.glsl" -import vertexShader from "./vertex.glsl" - -export const createNotFoundMaterial = (tDiffuse: { value: Texture }) => - new ShaderMaterial({ - side: FrontSide, - uniforms: { - tDiffuse: tDiffuse, - uTime: { value: 0 }, - resolution: { value: new Vector2(1024, 1024) } - }, - vertexShader, - fragmentShader - }) +import { + Fn, + float, + vec2, + vec3, + vec4, + uniform, + uv, + texture, + sin, + dot, + fract, + floor, + step +} from "three/tsl" +import type { NodeRepresentation } from "three/tsl" +import { FrontSide, Texture, Vector2 } from "three" +import { NodeMaterial } from "three/webgpu" + +const random = /* @__PURE__ */ Fn(([st]: [NodeRepresentation]) => { + return fract(sin(dot(st, vec2(12.9898, 78.233))).mul(43758.5453)) +}) + +const shakeOffset = /* @__PURE__ */ Fn( + ([time, intensity]: [NodeRepresentation, NodeRepresentation]) => { + const t = float(time) + const i = float(intensity) + const t03 = t.mul(0.3) + const t02 = t.mul(0.2) + const shakeX = random(vec2(t03, t03)).mul(2.0).sub(1.0) + const shakeY = random(vec2(t02, t02)).mul(2.0).sub(1.0) + const t05 = floor(t.mul(0.5)) + const shakeBurst = step(0.58, random(vec2(t05, t05))) + return vec2(shakeX, shakeY).mul(i).mul(shakeBurst) + } +) + +export const createNotFoundMaterial = () => { + const tDiffuse = texture(new Texture()) + const uTime = uniform(0) + const resolution = uniform(new Vector2(1024, 1024)) + + const material = new NodeMaterial() + material.side = FrontSide + + material.colorNode = Fn(() => { + const vUv = uv() + + // Shake offset + const shake = shakeOffset(uTime, float(0.0005)) + const uv2 = vUv.add(shake).toVar() + uv2.x.assign(float(1.0).sub(uv2.x)) + uv2.subAssign(0.5) + + // 180° rotation (cosR=-1, sinR=0) + re-center + const shiftedUv = uv2.mul(-1.0).add(0.5) + + // Texture sampling with color bleeding + const baseColor = tDiffuse.uv(shiftedUv) + const colorBleedUp = tDiffuse.uv(shiftedUv.add(vec2(0.0, 0.001))) + const colorBleedDown = tDiffuse.uv(shiftedUv.sub(vec2(0.0, 0.001))) + const colorWithBleed = baseColor.add( + colorBleedUp.add(colorBleedDown).mul(0.5) + ) + + // Scanlines: color.rgb *= (1 + scanline * 0.35) + const scanline = sin( + vUv.y.mul(resolution.y).mul(0.45).sub(uTime.mul(15.0)) + ) + const afterScanline = colorWithBleed.rgb.mul( + float(1.0).add(scanline.mul(0.35)) + ) + + // Noise + const noise = random(vUv.add(uTime.mul(5.0))) + const afterNoise = afterScanline.add(noise.mul(0.15)) + + // Subtle wave + const wave = sin(vUv.y.mul(10.0).add(uTime.mul(10.0))).mul(0.02) + const afterWave = afterNoise.add(wave) + + // Grayscale + tint + const grayscale = dot(afterWave, vec3(0.299, 0.587, 0.114)) + const tint = vec3(2.694, 2.694, 2.694) + + return vec4(vec3(grayscale, grayscale, grayscale).mul(tint), colorWithBleed.a) + })() + + return { material, uniforms: { tDiffuse, uTime, resolution } } +} diff --git a/src/shaders/material-not-found/vertex.glsl b/src/shaders/material-not-found/vertex.glsl deleted file mode 100644 index 2e26c456..00000000 --- a/src/shaders/material-not-found/vertex.glsl +++ /dev/null @@ -1,5 +0,0 @@ -varying vec2 vUv; -void main() { - vUv = uv; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); -} From e62bd4613bc5e6f4d899b6aecbf7c2b9f606e60b Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 16:12:46 -0300 Subject: [PATCH 07/21] feat: migrate material-screen from GLSL to TSL Convert the complex CRT/arcade screen shader from GLSL to TSL. Implements barrel distortion, interference, scanlines, pixelation with exclusion zones, color grading, reveal animation, vignette, and noise as TSL node operations using branchless mix/step patterns. Updated arcade-screen and arcade-game consumers to use the new { material, uniforms } return shape, removing ShaderMaterial dependency from arcade-game props. Co-Authored-By: Claude Opus 4.5 --- src/components/arcade-game/index.tsx | 20 +- src/components/arcade-screen/index.tsx | 38 ++-- src/shaders/material-screen/fragment.glsl | 207 ------------------- src/shaders/material-screen/index.ts | 234 ++++++++++++++++++++-- src/shaders/material-screen/vertex.glsl | 32 --- 5 files changed, 241 insertions(+), 290 deletions(-) delete mode 100644 src/shaders/material-screen/fragment.glsl delete mode 100644 src/shaders/material-screen/vertex.glsl diff --git a/src/components/arcade-game/index.tsx b/src/components/arcade-game/index.tsx index 3e1a8ac2..758073c4 100644 --- a/src/components/arcade-game/index.tsx +++ b/src/components/arcade-game/index.tsx @@ -1,8 +1,6 @@ import { useTexture } from "@react-three/drei" import { Container, DefaultProperties, Text } from "@react-three/uikit" import { useEffect, useRef, useState } from "react" -import type { ShaderMaterial } from "three" - import { COLORS_THEME } from "@/components/arcade-screen/screen-ui" import { useAssets } from "@/components/assets-provider" import { useCurrentScene } from "@/hooks/use-current-scene" @@ -23,10 +21,10 @@ import { Skybox } from "./skybox" interface arcadeGameProps { visible: boolean - screenMaterial: ShaderMaterial + screenUniforms: { uIsGameRunning: { value: number } } } -export const ArcadeGame = ({ visible, screenMaterial }: arcadeGameProps) => { +export const ArcadeGame = ({ visible, screenUniforms }: arcadeGameProps) => { const setSpeed = useRoad((s) => s.setSpeed) const speedRef = useRoad((s) => s.speedRef) const gameOver = useGame((s) => s.gameOver) @@ -52,32 +50,28 @@ export const ArcadeGame = ({ visible, screenMaterial }: arcadeGameProps) => { } // Set game running value without conditional check to prevent flickering - screenMaterial.uniforms.uIsGameRunning.value = 1.0 - screenMaterial.needsUpdate = true + screenUniforms.uIsGameRunning.value = 1.0 } else { // Set game not running value without conditional check - screenMaterial.uniforms.uIsGameRunning.value = 0.0 - screenMaterial.needsUpdate = true + screenUniforms.uIsGameRunning.value = 0.0 } }) useEffect(() => { if (gameStarted && !gameOver) { - screenMaterial.uniforms.uIsGameRunning.value = 1.0 - screenMaterial.needsUpdate = true + screenUniforms.uIsGameRunning.value = 1.0 scoreRef.current = 0 setScoreDisplay(0) lastUpdateTimeRef.current = 0 } else { - screenMaterial.uniforms.uIsGameRunning.value = 0.0 - screenMaterial.needsUpdate = true + screenUniforms.uIsGameRunning.value = 0.0 } const event = new CustomEvent("gameStateChange", { detail: { gameStarted, gameOver } }) window.dispatchEvent(event) - }, [gameStarted, gameOver, screenMaterial]) + }, [gameStarted, gameOver, screenUniforms]) useEffect(() => { if (scene !== "lab") { diff --git a/src/components/arcade-screen/index.tsx b/src/components/arcade-screen/index.tsx index a397f0f5..4f9addd8 100644 --- a/src/components/arcade-screen/index.tsx +++ b/src/components/arcade-screen/index.tsx @@ -60,7 +60,10 @@ export const ArcadeScreen = () => { texture.flipY = false }) const videoTexture = useVideoTexture(arcade.idleScreen, { loop: true }) - const screenMaterial = useMemo(() => createScreenMaterial(), []) + const { material: screenMaterial, uniforms: screenUniforms } = useMemo( + () => createScreenMaterial(), + [] + ) const renderTarget = useMemo(() => new WebGLRenderTarget(1024, 1024), []) // Use our custom hook to ensure video playback resumes when tab becomes visible @@ -86,35 +89,31 @@ export const ArcadeScreen = () => { if (!hasVisitedArcade) { if (isLabRoute) { - screenMaterial.uniforms.map.value = bootTexture - screenMaterial.uniforms.uRevealProgress = { value: 0.0 } + screenUniforms.map.value = bootTexture + screenUniforms.uRevealProgress.value = 0.0 animate(0, 1, { duration: 2, ease: [0.43, 0.13, 0.23, 0.96], onUpdate: (progress) => { - screenMaterial.uniforms.uRevealProgress.value = progress + screenUniforms.uRevealProgress.value = progress }, onComplete: () => { - if (screenMaterial.uniforms.uRevealProgress.value >= 0.99) { - screenMaterial.uniforms.map.value = renderTarget.texture + if (screenUniforms.uRevealProgress.value >= 0.99) { + screenUniforms.map.value = renderTarget.texture setHasVisitedArcade(true) - if (isInGame) { - screenMaterial.uniforms.uFlip = { value: 1 } - } else { - screenMaterial.uniforms.uFlip = { value: 0 } - } + screenUniforms.uFlip.value = isInGame ? 1 : 0 } } }) } else { - screenMaterial.uniforms.map.value = videoTexture - screenMaterial.uniforms.uRevealProgress = { value: 1.0 } - screenMaterial.uniforms.uFlip = { value: 0 } + screenUniforms.map.value = videoTexture + screenUniforms.uRevealProgress.value = 1.0 + screenUniforms.uFlip.value = 0 } } else { - screenMaterial.uniforms.map.value = renderTarget.texture - screenMaterial.uniforms.uFlip = { value: isInGame ? 1 : 0 } + screenUniforms.map.value = renderTarget.texture + screenUniforms.uFlip.value = isInGame ? 1 : 0 } arcadeScreen.material = screenMaterial @@ -126,13 +125,12 @@ export const ArcadeScreen = () => { isLabRoute, bootTexture, screenMaterial, + screenUniforms, isInGame ]) useFrameCallback((_, delta) => { - if (screenMaterial.uniforms.uTime) { - screenMaterial.uniforms.uTime.value += delta - } + screenUniforms.uTime.value += delta }) const CAMERA_CONFIGS = useMemo(() => { @@ -177,7 +175,7 @@ export const ArcadeScreen = () => { diff --git a/src/shaders/material-screen/fragment.glsl b/src/shaders/material-screen/fragment.glsl deleted file mode 100644 index 21ac7fcf..00000000 --- a/src/shaders/material-screen/fragment.glsl +++ /dev/null @@ -1,207 +0,0 @@ -precision mediump float; - -uniform sampler2D map; -uniform float uTime; -uniform float uRevealProgress; -uniform float uFlip; -uniform float uIsGameRunning; -varying vec2 vUv; -varying vec3 vPosition; - -#define TINT_R (1.33) -#define TINT_G (0.11) -#define BRIGHTNESS (15.0) -#define VIGNETTE_STRENGTH (0.1) -#define DISTORTION (0.3) -#define NOISE_INTENSITY (0.3) -#define TIME_SPEED (1.0) -#define LINE_HEIGHT (0.1) -#define MASK_INTENSITY (0.3) -#define MASK_SIZE (8.0) -#define MASK_BORDER (0.4) -#define INTERFERENCE1 (0.4) -#define INTERFERENCE2 (0.001) -#define SCANLINE_INTENSITY (0.2) -#define SCANLINE_COUNT (200.0) -#define NOISE_SCALE (500.0) -#define NOISE_OPACITY (0.01) - -#define SCAN_SPEED (5.0) -#define SCAN_CYCLE (10.0) -#define SCAN_DISTORTION (0.003) - -vec2 curveRemapUV(vec2 uv) { - uv = uv * 2.0 - 1.0; - vec2 offset = abs(uv.yx) / vec2(5.0, 5.0); - uv = uv + uv * offset * offset * DISTORTION; - uv = uv * 0.5 + 0.5; - return uv; -} - -float random(vec2 st) { - return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123); -} - -float peak(float x, float xpos, float scale) { - float d = abs(x - xpos); - float approxLog = d > 0.001 ? -4.605 * d + 2.0 : 6.0; - return clamp((1.0 - x) * scale * approxLog, 0.0, 1.0); -} - -void main() { - float scanCycleTime = mod(uTime * SCAN_SPEED, SCAN_CYCLE + 50.0); - float scanPos; - if (scanCycleTime < SCAN_CYCLE) { - scanPos = scanCycleTime / SCAN_CYCLE; - } else { - scanPos = 1.0; - } - - // Add interference - float scany = round(vUv.y * 1024.0); - - float r = random(vec2(0.0, scany) + vec2(uTime * 0.001)); - if (r > 0.995) { - r *= 3.0; - } - float ifx1 = INTERFERENCE1 * 2.0 / 1024.0 * r; - float ifx2 = INTERFERENCE2 * (r * peak(vUv.y, 0.2, 0.2)); - - vec2 interferenceUv = vUv; - interferenceUv.x += ifx1 - ifx2; - - vec2 remappedUv = curveRemapUV(interferenceUv); - - if (uFlip == 1.0) { - remappedUv.y = 1.0 - remappedUv.y; - - // Add pixelation that excludes a center square - float pixelSize = 300.0; - vec2 centeredUv = remappedUv - 0.5; - - // Square parameters directly in the main function - float squareWidth = 0.25; - float squareHeight = 0.05; - float squareX = -0.01; - float squareY = -0.4; - - vec2 squareCenter = vec2(squareX, squareY); - vec2 relativeToSquare = centeredUv - squareCenter; - vec2 absRelToSquare = abs(relativeToSquare); - bool insideSquare = - absRelToSquare.x < squareWidth && absRelToSquare.y < squareHeight; - - // Score square parameters - float centerSquareWidth = 0.2; - float centerSquareHeight = 0.2; - float centerSquareX = -0.35; - float centerSquareY = 0.5; - - vec2 centerSquareCenter = vec2(centerSquareX, centerSquareY); - vec2 relativeToCenterSquare = centeredUv - centerSquareCenter; - - bool insideCenterSquare = - abs(relativeToCenterSquare.x) < centerSquareWidth && - abs(relativeToCenterSquare.y) < centerSquareHeight; - - vec3 textureColor = vec3(0.0); - - // Calculate texture boundaries once at the beginning - bool validUV = - remappedUv.x >= 0.0 && - remappedUv.x <= 1.0 && - remappedUv.y >= 0.0 && - remappedUv.y <= 1.0; - - // Use this variable where needed - if (validUV) { - textureColor = texture2D(map, remappedUv).rgb; - } - - // Simplification of conditional logic - bool shouldApplyPixelation = - uIsGameRunning > 0.5 && !insideCenterSquare || - uIsGameRunning <= 0.5 && !insideSquare && !insideCenterSquare; - - if (shouldApplyPixelation) { - remappedUv = floor(remappedUv * pixelSize) / pixelSize; - - if ( - remappedUv.x >= 0.0 && - remappedUv.x <= 1.0 && - remappedUv.y >= 0.0 && - remappedUv.y <= 1.0 - ) { - textureColor = texture2D(map, remappedUv).rgb; - } - } - - // Only show the square when game is NOT running - if (insideSquare && uIsGameRunning <= 0.5) { - // Apply some visual effect to the square to make it visible - vec3 squareColor = vec3(1.0, 1.0, 0.0); // Yellow color - textureColor = mix(textureColor, squareColor, 0.3); - } - } - - // Add horizontal distortion near scan line - float y = (vUv.y - scanPos) * 160.0; - float expApprox = 1.0 / (1.0 + y * y * 0.5 + y * y * y * y * 0.125); - float scanDistortion = expApprox * SCAN_DISTORTION; - remappedUv.x += scanDistortion; - - vec3 textureColor = vec3(0.0); - - // Calculate texture boundaries once at the beginning - bool validUV = - remappedUv.x >= 0.0 && - remappedUv.x <= 1.0 && - remappedUv.y >= 0.0 && - remappedUv.y <= 1.0; - - // Use this variable where needed - if (validUV) { - textureColor = texture2D(map, remappedUv).rgb; - } - - float luma = dot(textureColor, vec3(0.8, 0.1, 0.1)); - vec3 tint = vec3(1.0, 0.302, 0.0); - tint.r *= TINT_R; - tint.g *= TINT_G; - textureColor = luma * tint * BRIGHTNESS; - - float currentLine = floor(vUv.y / LINE_HEIGHT); - float revealLine = floor(uRevealProgress / LINE_HEIGHT); - float textureVisibility = currentLine <= revealLine ? 0.8 : 0.0; - - // Start with black background and blend with revealed texture - vec3 color = mix(vec3(0.0), textureColor, textureVisibility); - - // Add vignette - vec2 vignetteUv = vUv * 2.0 - 1.0; - float vignette = - 1.0 - min(1.0, dot(vignetteUv, vignetteUv) * VIGNETTE_STRENGTH); - color *= vignette; - - // Add noise overlay - vec2 noiseUv = gl_FragCoord.xy / NOISE_SCALE; - float noise = random(noiseUv + uTime); - color = mix(color, color + vec3(noise), NOISE_OPACITY); - - // Add orange tint to black areas - vec3 orangeTint = vec3(0.2, 0.05, 0.0); - float blackThreshold = 0.01; - float luminance = dot(color, vec3(0.299, 0.587, 0.114)); - color = mix(color + orangeTint * 0.1, color, step(blackThreshold, luminance)); - - // Add scanlines - float scanline = step(0.5, fract(vPosition.y * SCANLINE_COUNT)); - scanline = mix(1.0, 0.7, scanline * SCANLINE_INTENSITY); - color = mix( - color, - color * scanline + vec3(0.1, 0.025, 0.0) * (1.0 - scanline), - 0.5 - ); - - gl_FragColor = vec4(color, 1.0); -} diff --git a/src/shaders/material-screen/index.ts b/src/shaders/material-screen/index.ts index 50fc7ec5..538c4d93 100644 --- a/src/shaders/material-screen/index.ts +++ b/src/shaders/material-screen/index.ts @@ -1,18 +1,216 @@ -import { ShaderMaterial } from "three" - -import fragmentShader from "./fragment.glsl" -import vertexShader from "./vertex.glsl" - -export const createScreenMaterial = () => - new ShaderMaterial({ - transparent: true, - uniforms: { - uTime: { value: 0 }, - map: { value: null }, - uRevealProgress: { value: 1.0 }, - uFlip: { value: 0 }, - uIsGameRunning: { value: 0.0 } - }, - vertexShader, - fragmentShader - }) +import { + Fn, + float, + vec2, + vec3, + vec4, + uniform, + uv, + texture, + sin, + dot, + fract, + floor, + step, + abs, + clamp, + mod, + mix, + min, + round, + positionLocal, + screenCoordinate +} from "three/tsl" +import type { NodeRepresentation } from "three/tsl" +import { FrontSide, Texture } from "three" +import { NodeMaterial } from "three/webgpu" + +const DISTORTION = 0.3 +const TINT_R = 1.33 +const TINT_G = 0.11 +const BRIGHTNESS = 15.0 +const VIGNETTE_STRENGTH = 0.1 +const LINE_HEIGHT = 0.1 +const INTERFERENCE1 = 0.4 +const INTERFERENCE2 = 0.001 +const SCANLINE_INTENSITY = 0.2 +const SCANLINE_COUNT = 200.0 +const NOISE_SCALE = 500.0 +const NOISE_OPACITY = 0.01 +const SCAN_SPEED = 5.0 +const SCAN_CYCLE = 10.0 +const SCAN_DISTORTION = 0.003 + +const random = /* @__PURE__ */ Fn(([st]: [NodeRepresentation]) => { + return fract(sin(dot(st, vec2(12.9898, 78.233))).mul(43758.5453123)) +}) + +const curveRemapUV = /* @__PURE__ */ Fn(([uvIn]: [NodeRepresentation]) => { + const u = vec2(uvIn).toVar() + u.assign(u.mul(2.0).sub(1.0)) + const offset = abs(vec2(u.y, u.x)).div(5.0) + u.assign(u.add(u.mul(offset).mul(offset).mul(DISTORTION))) + u.assign(u.mul(0.5).add(0.5)) + return u +}) + +const peak = /* @__PURE__ */ Fn( + ([x, xpos, scale]: [ + NodeRepresentation, + NodeRepresentation, + NodeRepresentation + ]) => { + const xf = float(x) + const xposf = float(xpos) + const scalef = float(scale) + const d = abs(xf.sub(xposf)) + const approxLog = mix(float(6.0), d.mul(-4.605).add(2.0), step(0.001, d)) + return clamp(float(1.0).sub(xf).mul(scalef).mul(approxLog), 0.0, 1.0) + } +) + +export const createScreenMaterial = () => { + const mapTex = texture(new Texture()) + const uTime = uniform(0) + const uRevealProgress = uniform(1.0) + const uFlip = uniform(0) + const uIsGameRunning = uniform(0.0) + + const material = new NodeMaterial() + material.side = FrontSide + + material.colorNode = Fn(() => { + const vUv = uv() + const vPos = positionLocal + + // Scan cycle + const scanCycleTime = mod(uTime.mul(SCAN_SPEED), float(SCAN_CYCLE + 50.0)) + const scanPos = mix( + scanCycleTime.div(SCAN_CYCLE), + float(1.0), + step(SCAN_CYCLE, scanCycleTime) + ) + + // Interference + const scany = round(vUv.y.mul(1024.0)) + const r = random( + vec2(uTime.mul(0.001), scany.add(uTime.mul(0.001))) + ).toVar() + r.assign(mix(r, r.mul(3.0), step(0.995, r))) + + const ifx1 = float(INTERFERENCE1).mul(2.0).div(1024.0).mul(r) + const ifx2 = float(INTERFERENCE2).mul( + r.mul(peak(vUv.y, float(0.2), float(0.2))) + ) + + const interferenceUv = vUv.toVar() + interferenceUv.x.addAssign(ifx1.sub(ifx2)) + + // CRT curve remap + const remappedUv = curveRemapUV(interferenceUv).toVar() + + // Flip y when uFlip is 1 + remappedUv.y.assign( + mix(remappedUv.y, float(1.0).sub(remappedUv.y), uFlip) + ) + + // Pixelation with exclusion zones (only when flipped) + const centeredUv = remappedUv.sub(0.5) + + const relSquare = abs(centeredUv.sub(vec2(-0.01, -0.4))) + const inSquare = float(1.0) + .sub(step(0.25, relSquare.x)) + .mul(float(1.0).sub(step(0.05, relSquare.y))) + + const relCenter = abs(centeredUv.sub(vec2(-0.35, 0.5))) + const inCenterSquare = float(1.0) + .sub(step(0.2, relCenter.x)) + .mul(float(1.0).sub(step(0.2, relCenter.y))) + + const notInCenter = float(1.0).sub(inCenterSquare) + const notInSquare = float(1.0).sub(inSquare) + const gameRunning = step(0.5, uIsGameRunning) + + // shouldPixelate = uFlip AND !center AND (gameRunning OR !square) + const shouldPixelate = uFlip + .mul(notInCenter) + .mul(gameRunning.add(notInSquare).sub(gameRunning.mul(notInSquare))) + + const pixelatedUv = floor(remappedUv.mul(300.0)).div(300.0) + remappedUv.assign(mix(remappedUv, pixelatedUv, shouldPixelate)) + + // Scan distortion + const yd = vUv.y.sub(scanPos).mul(160.0) + const yd2 = yd.mul(yd) + const yd4 = yd2.mul(yd2) + const expApprox = float(1.0).div( + float(1.0).add(yd2.mul(0.5)).add(yd4.mul(0.125)) + ) + remappedUv.x.addAssign(expApprox.mul(SCAN_DISTORTION)) + + // Texture sampling with boundary check + const validX = step(0.0, remappedUv.x).mul( + step(0.0, float(1.0).sub(remappedUv.x)) + ) + const validY = step(0.0, remappedUv.y).mul( + step(0.0, float(1.0).sub(remappedUv.y)) + ) + const validUV = validX.mul(validY) + + const textureColor = mapTex.uv(remappedUv).rgb.mul(validUV).toVar() + + // Color grading: luma, tint, brightness + const luma = dot(textureColor, vec3(0.8, 0.1, 0.1)) + const tint = vec3(1.0 * TINT_R, 0.302 * TINT_G, 0.0) + textureColor.assign(tint.mul(luma).mul(BRIGHTNESS)) + + // Line reveal + const currentLine = floor(vUv.y.div(LINE_HEIGHT)) + const revealLine = floor(uRevealProgress.div(LINE_HEIGHT)) + const textureVisibility = step(currentLine, revealLine).mul(0.8) + + const color = textureColor.mul(textureVisibility).toVar() + + // Vignette + const vignetteUv = vUv.mul(2.0).sub(1.0) + const vignette = float(1.0).sub( + min(float(1.0), dot(vignetteUv, vignetteUv).mul(VIGNETTE_STRENGTH)) + ) + color.assign(color.mul(vignette)) + + // Noise overlay + const noiseUv = screenCoordinate.xy.div(NOISE_SCALE) + const noise = random(noiseUv.add(uTime)) + color.assign(color.add(noise.mul(NOISE_OPACITY))) + + // Orange tint on dark areas + const orangeTint = vec3(0.2, 0.05, 0.0) + const luminance = dot(color, vec3(0.299, 0.587, 0.114)) + const isNotBlack = step(0.01, luminance) + color.assign(mix(color.add(orangeTint.mul(0.1)), color, isNotBlack)) + + // Scanlines + const scanline = step(0.5, fract(vPos.y.mul(SCANLINE_COUNT))) + const scanlineFactor = mix( + float(1.0), + float(0.7), + scanline.mul(SCANLINE_INTENSITY) + ) + color.assign( + mix( + color, + color + .mul(scanlineFactor) + .add(vec3(0.1, 0.025, 0.0).mul(float(1.0).sub(scanlineFactor))), + 0.5 + ) + ) + + return vec4(color, 1.0) + })() + + return { + material, + uniforms: { map: mapTex, uTime, uRevealProgress, uFlip, uIsGameRunning } + } +} diff --git a/src/shaders/material-screen/vertex.glsl b/src/shaders/material-screen/vertex.glsl deleted file mode 100644 index 1b56d4e8..00000000 --- a/src/shaders/material-screen/vertex.glsl +++ /dev/null @@ -1,32 +0,0 @@ -#include -#include -#include -#include - -#ifdef USE_MULTI_MAP -attribute float mapIndex; -varying float vMapIndex; -#endif - -varying vec2 vUv; -varying vec3 vNormal; -varying vec3 vPosition; - -void main() { - #include - #include - #include - #include - #include - #include - #include - #include - - vUv = uv; - vNormal = normal; - vPosition = position; - - #ifdef USE_MULTI_MAP - vMapIndex = mapIndex; - #endif -} From 055be1b4ecefc89e5ca06254f4df24f18176e8c6 Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 16:18:29 -0300 Subject: [PATCH 08/21] feat: migrate routing-element shader from GLSL to TSL Inline the border/diagonal-pattern shader using NodeMaterial + TSL. Uses dFdx/dFdy for pixel-accurate border detection and screenUV for the diagonal line pattern. Co-Authored-By: Claude Opus 4.5 --- src/components/routing-element/frag.glsl | 63 --------- .../routing-element/routing-element.tsx | 120 ++++++++++++++---- src/components/routing-element/vert.glsl | 8 -- 3 files changed, 96 insertions(+), 95 deletions(-) delete mode 100644 src/components/routing-element/frag.glsl delete mode 100644 src/components/routing-element/vert.glsl diff --git a/src/components/routing-element/frag.glsl b/src/components/routing-element/frag.glsl deleted file mode 100644 index 1175a57f..00000000 --- a/src/components/routing-element/frag.glsl +++ /dev/null @@ -1,63 +0,0 @@ -varying vec2 vUv; -varying vec4 vPos; -uniform vec2 resolution; -uniform float opacity; -uniform float borderPadding; - -void main() { - // add border - float borderThickness = 1.3 + borderPadding; - - vec2 dwdx = dFdx(vUv); - vec2 dwdy = dFdy(vUv); - float pixelWidth = sqrt(dwdx.x * dwdx.x + dwdy.x * dwdy.x); - float pixelHeight = sqrt(dwdx.y * dwdx.y + dwdy.y * dwdy.y); - - vec2 uvBorderSize = vec2( - borderThickness * pixelWidth, - borderThickness * pixelHeight - ); - - vec2 uvBorderPadding = vec2( - borderPadding * pixelWidth, - borderPadding * pixelHeight - ); - - vec2 distFromEdge = min(vUv, 1.0 - vUv); - - bool isPadding = - distFromEdge.x < uvBorderPadding.x || distFromEdge.y < uvBorderPadding.y; - - bool isBorder = - distFromEdge.x < uvBorderSize.x || distFromEdge.y < uvBorderSize.y; - - // add diagonals - vec2 vCoords = vPos.xy; - vCoords /= vPos.w; - vCoords = vCoords * 0.5 + 0.5; - - float aspectRatio = resolution.x / resolution.y; - vCoords.x *= aspectRatio; - - float lineSpacing = 0.006; - float lineThickness = 0.2; - float lineOpacity = 0.15; - - float diagonal = (vCoords.x - vCoords.y) / (lineSpacing * sqrt(2.0)); - float center = 0.5; - float halfWidth = lineThickness * 0.5; - float pattern = - smoothstep(center - halfWidth, center, fract(diagonal)) - - smoothstep(center, center + halfWidth, fract(diagonal)); - float line = pattern; - - if (isPadding) { - discard; - } - - if (isBorder) { - gl_FragColor = vec4(1.0, 1.0, 1.0, 0.2 * opacity); - } else { - gl_FragColor = vec4(vec3(1.0), line * lineOpacity * opacity); - } -} diff --git a/src/components/routing-element/routing-element.tsx b/src/components/routing-element/routing-element.tsx index 32c7c9f4..55fc4b1e 100644 --- a/src/components/routing-element/routing-element.tsx +++ b/src/components/routing-element/routing-element.tsx @@ -4,7 +4,25 @@ import { animate } from "motion/react" import { usePathname, useRouter } from "next/navigation" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { memo } from "react" -import { Mesh, ShaderMaterial } from "three" +import { Mesh, Vector2 } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec3, + uniform, + uv, + dFdx, + dFdy, + sqrt, + min, + max, + step, + smoothstep, + fract, + mix, + screenUV +} from "three/tsl" import { useInspectable } from "@/components/inspectables/context" import { useNavigationStore } from "@/components/navigation-handler/navigation-store" @@ -12,9 +30,7 @@ import { useHandleNavigation } from "@/hooks/use-handle-navigation" import { useCursor } from "@/hooks/use-mouse" import { valueRemap } from "../arcade-game/lib/math" -import fragmentShader from "./frag.glsl" import { RoutingPlus } from "./routing-plus" -import vertexShader from "./vert.glsl" interface RoutingElementProps { node: Mesh @@ -29,33 +45,89 @@ const RoutingElementComponent = ({ hoverName, groupName }: RoutingElementProps) => { - const { routingMaterial, updateMaterialResolution } = useMemo(() => { - const routingMaterial = new ShaderMaterial({ - depthWrite: false, - depthTest: false, - transparent: true, - fragmentShader: fragmentShader, - vertexShader: vertexShader, - uniforms: { - resolution: { value: [] }, - opacity: { value: 0 }, - borderPadding: { value: 0 } - } - }) + const { routingMaterial, routingUniforms } = useMemo(() => { + const uResolution = uniform(new Vector2()) + const uOpacity = uniform(0) + const uBorderPadding = uniform(0) + + const material = new NodeMaterial() + material.depthWrite = false + material.depthTest = false + material.transparent = true + + material.colorNode = vec3(1.0, 1.0, 1.0) + + material.opacityNode = Fn(() => { + const vUv = uv() + + // Border detection using UV derivatives + const dwdx = dFdx(vUv) + const dwdy = dFdy(vUv) + const pixelWidth = sqrt(dwdx.x.mul(dwdx.x).add(dwdy.x.mul(dwdy.x))) + const pixelHeight = sqrt(dwdx.y.mul(dwdx.y).add(dwdy.y.mul(dwdy.y))) + + const borderThickness = float(1.3).add(uBorderPadding) + const uvBorderSizeX = borderThickness.mul(pixelWidth) + const uvBorderSizeY = borderThickness.mul(pixelHeight) + const uvBorderPaddingX = uBorderPadding.mul(pixelWidth) + const uvBorderPaddingY = uBorderPadding.mul(pixelHeight) + + const distFromEdgeX = min(vUv.x, float(1.0).sub(vUv.x)) + const distFromEdgeY = min(vUv.y, float(1.0).sub(vUv.y)) + + // isPadding: near-edge within padding distance (0/1) + const isPadding = max( + float(1.0).sub(step(uvBorderPaddingX, distFromEdgeX)), + float(1.0).sub(step(uvBorderPaddingY, distFromEdgeY)) + ) - // routingMaterial.customProgramCacheKey = () => "routing-element-material" + // isBorder: within border region (0/1) + const isBorder = max( + float(1.0).sub(step(uvBorderSizeX, distFromEdgeX)), + float(1.0).sub(step(uvBorderSizeY, distFromEdgeY)) + ) - const updateMaterialResolution = (width: number, height: number) => { - routingMaterial.uniforms.resolution.value = [width, height] - } + // Diagonal line pattern using screen coordinates + const vCoords = screenUV.toVar() + const aspectRatio = uResolution.x.div(uResolution.y) + vCoords.x.mulAssign(aspectRatio) + + const lineSpacing = 0.006 + const lineThickness = 0.2 + const lineOpacity = 0.15 + + const diagonal = vCoords.x + .sub(vCoords.y) + .div(float(lineSpacing).mul(sqrt(float(2.0)))) + const halfWidth = float(lineThickness).mul(0.5) + const fractDiag = fract(diagonal) + const pattern = smoothstep(float(0.5).sub(halfWidth), float(0.5), fractDiag) + .sub(smoothstep(float(0.5), float(0.5).add(halfWidth), fractDiag)) - return { routingMaterial, updateMaterialResolution } + // Discard padding area + isPadding.greaterThan(0.5).discard() + + // Border: 0.2 * opacity, Interior: pattern * lineOpacity * opacity + const borderAlpha = float(0.2).mul(uOpacity) + const interiorAlpha = pattern.mul(lineOpacity).mul(uOpacity) + + return mix(interiorAlpha, borderAlpha, isBorder) + })() + + return { + routingMaterial: material, + routingUniforms: { + resolution: uResolution, + opacity: uOpacity, + borderPadding: uBorderPadding + } + } }, []) const screenWidth = useThree((state) => state.size.width) const screenHeight = useThree((state) => state.size.height) - updateMaterialResolution(screenWidth, screenHeight) + routingUniforms.resolution.value.set(screenWidth, screenHeight) const router = useRouter() const pathname = usePathname() @@ -254,8 +326,8 @@ const RoutingElementComponent = ({ if (!outlines || !cross) return const s = valueRemap(v, 0, 1, 10, 0) - routingMaterial.uniforms.borderPadding.value = s - routingMaterial.uniforms.opacity.value = v + routingUniforms.borderPadding.value = s + routingUniforms.opacity.value = v }) return ( diff --git a/src/components/routing-element/vert.glsl b/src/components/routing-element/vert.glsl deleted file mode 100644 index 977d66a0..00000000 --- a/src/components/routing-element/vert.glsl +++ /dev/null @@ -1,8 +0,0 @@ -varying vec2 vUv; -varying vec4 vPos; - -void main() { - vUv = uv; - vPos = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0); - gl_Position = vPos; -} From 8697ab4971416bc0f20d2c3dc1bb8ced179abb35 Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 16:24:30 -0300 Subject: [PATCH 09/21] feat: migrate sparkles shader from GLSL to TSL Replace shaderMaterial/extend pattern with PointsNodeMaterial and TSL attribute nodes. Adds time getter/setter for drei Sparkles compatibility. Pulse animation and jitter computed inline via TSL. Co-Authored-By: Claude Opus 4.5 --- src/components/sparkles/frag.glsl | 13 ---- src/components/sparkles/index.tsx | 116 +++++++++++++++++++++++------- src/components/sparkles/vert.glsl | 49 ------------- 3 files changed, 91 insertions(+), 87 deletions(-) delete mode 100644 src/components/sparkles/frag.glsl delete mode 100644 src/components/sparkles/vert.glsl diff --git a/src/components/sparkles/frag.glsl b/src/components/sparkles/frag.glsl deleted file mode 100644 index de87ad59..00000000 --- a/src/components/sparkles/frag.glsl +++ /dev/null @@ -1,13 +0,0 @@ -varying vec3 vColor; -varying float vOpacity; -varying vec3 vWorldPosition; -uniform float fadeFactor; - -void main() { - vec3 worldPosition = vWorldPosition; - float distanceToCenter = distance(worldPosition, vec3(1.0)); - gl_FragColor = vec4(vColor, vOpacity * (1.0 - fadeFactor)); - - #include - #include -} diff --git a/src/components/sparkles/index.tsx b/src/components/sparkles/index.tsx index 8638f623..8a2d6e1a 100644 --- a/src/components/sparkles/index.tsx +++ b/src/components/sparkles/index.tsx @@ -1,23 +1,30 @@ -import { shaderMaterial, Sparkles as SparklesImpl } from "@react-three/drei" -import { extend } from "@react-three/fiber" -import { useRef } from "react" +import { Sparkles as SparklesImpl } from "@react-three/drei" +import { useMemo } from "react" import * as THREE from "three" +import { PointsNodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec3, + uniform, + attribute, + positionLocal, + sin, + cos, + max, + dot, + fract, + mod, + smoothstep, + step, + clamp +} from "three/tsl" import { BASE_CONFIG, SPAWN_POINTS } from "@/constants/sparkles" import { useDeviceDetect } from "@/hooks/use-device-detect" import { useFrameCallback } from "@/hooks/use-pausable-time" import { useFadeAnimation } from "../inspectables/use-fade-animation" -import frag from "./frag.glsl" -import vert from "./vert.glsl" - -const SparklesMaterial = shaderMaterial( - { time: 0, pixelRatio: 2, fadeFactor: 0 }, - vert, - frag -) - -extend({ SparklesMaterial }) interface SparklesProps { count?: number @@ -30,25 +37,84 @@ interface SparklesProps { } export const Sparkle = (props: SparklesProps) => { - const ref = useRef(null) + const { material, uniforms } = useMemo(() => { + const uTime = uniform(0) + const uPixelRatio = uniform(2) + const uFadeFactor = uniform(0) + + const aSize = attribute("size", "float") + const aSpeed = attribute("speed", "float") + const aOpacity = attribute("opacity", "float") + const aNoise = attribute("noise", "vec3") + const aColor = attribute("color", "vec3") + + const mat = new PointsNodeMaterial() + mat.transparent = true + mat.depthWrite = false + mat.sizeAttenuation = false + + // Position with jitter + mat.positionNode = Fn(() => { + const pos = positionLocal.toVar() + pos.x.addAssign( + sin(uTime.mul(aSpeed).add(pos.x.mul(aNoise.x).mul(100.0))).mul(0.2) + ) + pos.y.addAssign( + cos(uTime.mul(aSpeed).add(pos.y.mul(aNoise.y).mul(100.0))).mul(0.2) + ) + pos.z.addAssign( + cos(uTime.mul(aSpeed).add(pos.z.mul(aNoise.z).mul(100.0))).mul(0.2) + ) + return pos + })() + + // Point size: size * pixelRatio, min 1px + ;(mat as any).sizeNode = max(aSize.mul(uPixelRatio), 1.0) + + // Color + mat.colorNode = aColor + + // Opacity with pulse animation + mat.opacityNode = Fn(() => { + const seed = fract( + sin(dot(positionLocal, vec3(12.9898, 78.233, 45.164))).mul(43758.5453) + ).mul(10.0) + const cycle = mod(uTime.mul(aSpeed).add(seed.mul(10.0)), 10.0) + const fadeIn = smoothstep(0.0, 0.3, cycle) + const fadeOut = smoothstep(1.0, 0.7, cycle) + const pulse = step(cycle, 1.0).mul(fadeIn).mul(fadeOut) + return clamp(aOpacity.mul(pulse), 0.0, 1.0) + .mul(0.5) + .mul(float(1.0).sub(uFadeFactor)) + })() + + // drei's Sparkles updates material.time via getter/setter + Object.defineProperty(mat, "time", { + get: () => uTime.value, + set: (v: number) => { + uTime.value = v + } + }) + + return { + material: mat, + uniforms: { + time: uTime, + pixelRatio: uPixelRatio, + fadeFactor: uFadeFactor + } + } + }, []) + const { fadeFactor } = useFadeAnimation() useFrameCallback(() => { - if (ref.current) { - // @ts-ignore - ref.current.uniforms.fadeFactor.value = fadeFactor.current.get() - } + uniforms.fadeFactor.value = fadeFactor.current.get() }) return ( - {/* @ts-ignore */} - + ) } diff --git a/src/components/sparkles/vert.glsl b/src/components/sparkles/vert.glsl deleted file mode 100644 index 1bca84f0..00000000 --- a/src/components/sparkles/vert.glsl +++ /dev/null @@ -1,49 +0,0 @@ -uniform float pixelRatio; -uniform float time; -attribute float size; -attribute float speed; -attribute float opacity; -attribute vec3 noise; -attribute vec3 color; - -varying vec3 vColor; -varying float vOpacity; -varying vec3 vWorldPosition; - -void main() { - vec4 modelPosition = modelMatrix * vec4(position, 1.0); - - // Create quick jittery movement - modelPosition.x += - sin(time * speed + modelPosition.x * noise.x * 100.0) * 0.2; - modelPosition.y += - cos(time * speed + modelPosition.y * noise.y * 100.0) * 0.2; - modelPosition.z += - cos(time * speed + modelPosition.z * noise.z * 100.0) * 0.2; - - vec4 viewPosition = viewMatrix * modelPosition; - vec4 projectionPostion = projectionMatrix * viewPosition; - gl_Position = projectionPostion; - - // Calculate point size to match exactly one pixel on screen - float pointSize = size * pixelRatio; - gl_PointSize = max(pointSize, 1.0); - // gl_PointSize *= (1.0 / -viewPosition.z); - - vColor = color; - - // Create a unique seed for each particle based on its position - float seed = - fract(sin(dot(position.xyz, vec3(12.9898, 78.233, 45.164))) * 43758.5453) * - 10.0; - - // Create a pulse with fade in/out - float cycle = mod(time * speed + seed * 10.0, 10.0); // 10 second cycle - float fadeIn = smoothstep(0.0, 0.3, cycle); // Fade in over 0.3s - float fadeOut = smoothstep(1.0, 0.7, cycle); // Fade out over 0.3s - float pulse = step(cycle, 1.0) * fadeIn * fadeOut; // Visible for 1s with fades - - vOpacity = clamp(opacity * pulse, 0.0, 1.0) * 0.5; - - vWorldPosition = modelPosition.xyz; -} From 06aa0dba28710475380a284163bd9656972b5262 Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 16:26:31 -0300 Subject: [PATCH 10/21] feat: migrate material-net from GLSL to TSL Replace ShaderMaterial with NodeMaterial + TSL for basketball net displacement animation. Uses attribute('uv1') for DataTexture-based vertex displacement lookup. Removed dead vertexCount uniform. Co-Authored-By: Claude Opus 4.5 --- src/components/basketball/net.tsx | 27 +++++---- src/shaders/material-net/fragment.glsl | 6 -- src/shaders/material-net/index.ts | 79 ++++++++++++++++---------- src/shaders/material-net/vertex.glsl | 25 -------- 4 files changed, 62 insertions(+), 75 deletions(-) delete mode 100644 src/shaders/material-net/fragment.glsl delete mode 100644 src/shaders/material-net/vertex.glsl diff --git a/src/components/basketball/net.tsx b/src/components/basketball/net.tsx index d321af6e..c25d2b3b 100644 --- a/src/components/basketball/net.tsx +++ b/src/components/basketball/net.tsx @@ -1,6 +1,6 @@ import { useFrame, useLoader } from "@react-three/fiber" import { useEffect, useRef, useState } from "react" -import { Mesh, NearestFilter, ShaderMaterial, Texture } from "three" +import { Mesh, NearestFilter, Texture } from "three" import { EXRLoader } from "three/examples/jsm/Addons.js" import { useMesh } from "@/hooks/use-mesh" @@ -14,7 +14,9 @@ const OFFSET_SCALE = 1.5 export const Net = () => { const meshRef = useRef(null) - const materialRef = useRef(null) + const uniformsRef = useRef["uniforms"] | null>(null) const progressRef = useRef(0) const isAnimatingRef = useRef(false) const textureRef = useRef(null) @@ -47,19 +49,16 @@ export const Net = () => { texture.minFilter = NearestFilter texture.generateMipmaps = false - const shaderMaterial = createNetMaterial({ - offsets, - texture, - totalFrames: TOTAL_FRAMES, - offsetScale: OFFSET_SCALE, - vertexCount: net.geometry.attributes.position.count - }) + const { material: netMat, uniforms } = createNetMaterial() + uniforms.tDisplacement.value = offsets + uniforms.map.value = texture + uniforms.totalFrames.value = TOTAL_FRAMES + uniforms.offsetScale.value = OFFSET_SCALE - materialRef.current = shaderMaterial + uniformsRef.current = uniforms meshRef.current = net - meshRef.current.material = shaderMaterial + meshRef.current.material = netMat - shaderMaterial.needsUpdate = true texture.needsUpdate = true offsets.needsUpdate = true @@ -70,11 +69,11 @@ export const Net = () => { }, [net, offsets]) useFrame((_, delta) => { - if (materialRef.current && isAnimatingRef.current) { + if (uniformsRef.current && isAnimatingRef.current) { progressRef.current += delta const currentFrame = (progressRef.current * ANIMATION_SPEED) % TOTAL_FRAMES - materialRef.current.uniforms.currentFrame.value = currentFrame + uniformsRef.current.currentFrame.value = currentFrame if (currentFrame >= TOTAL_FRAMES - 1) { isAnimatingRef.current = false diff --git a/src/shaders/material-net/fragment.glsl b/src/shaders/material-net/fragment.glsl deleted file mode 100644 index 44658bd8..00000000 --- a/src/shaders/material-net/fragment.glsl +++ /dev/null @@ -1,6 +0,0 @@ -varying vec2 vUv; -uniform sampler2D map; - -void main() { - gl_FragColor = texture2D(map, vUv); -} diff --git a/src/shaders/material-net/index.ts b/src/shaders/material-net/index.ts index 3a10823b..4168bcb1 100644 --- a/src/shaders/material-net/index.ts +++ b/src/shaders/material-net/index.ts @@ -1,34 +1,53 @@ -import { DataTexture, DoubleSide, ShaderMaterial, Texture } from "three" +import { DataTexture, DoubleSide, Texture } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + vec2, + float, + uniform, + uv, + texture, + attribute, + positionLocal +} from "three/tsl" -import fragmentShader from "./fragment.glsl" -import vertexShader from "./vertex.glsl" +export const createNetMaterial = () => { + const tDisplacement = texture(new DataTexture()) + const tMap = texture(new Texture()) + const uCurrentFrame = uniform(0) + const uTotalFrames = uniform(1) + const uOffsetScale = uniform(1) -interface NetMaterialProps { - offsets: DataTexture - texture: Texture - totalFrames: number - offsetScale: number - vertexCount: number -} + const material = new NodeMaterial() + material.transparent = true + material.side = DoubleSide -export const createNetMaterial = ({ - offsets, - texture, - totalFrames, - offsetScale, - vertexCount -}: NetMaterialProps) => - new ShaderMaterial({ - transparent: true, - side: DoubleSide, + // Vertex: displace position using DataTexture lookup via uv1 attribute + material.positionNode = Fn(() => { + const aUv1 = attribute("uv1", "vec2") + const dispUv = vec2( + aUv1.x, + float(1.0).sub(uCurrentFrame.div(uTotalFrames)) + ) + const offset = tDisplacement.uv(dispUv).xzy + const pos = positionLocal.toVar() + pos.addAssign(offset.mul(uOffsetScale)) + return pos + })() + + // Fragment: sample diffuse texture + const texSample = tMap.uv(uv()) + material.colorNode = texSample.rgb + material.opacityNode = texSample.a + + return { + material, uniforms: { - tDisplacement: { value: offsets }, - map: { value: texture }, - currentFrame: { value: 0 }, - totalFrames: { value: totalFrames }, - offsetScale: { value: offsetScale }, - vertexCount: { value: vertexCount } - }, - vertexShader, - fragmentShader - }) + tDisplacement, + map: tMap, + currentFrame: uCurrentFrame, + totalFrames: uTotalFrames, + offsetScale: uOffsetScale + } + } +} diff --git a/src/shaders/material-net/vertex.glsl b/src/shaders/material-net/vertex.glsl deleted file mode 100644 index 06ad0dbd..00000000 --- a/src/shaders/material-net/vertex.glsl +++ /dev/null @@ -1,25 +0,0 @@ -uniform sampler2D tDisplacement; -uniform float currentFrame; -uniform float totalFrames; -uniform float offsetScale; -uniform float vertexCount; - -attribute vec2 uv1; - -varying vec2 vUv; -varying vec2 vDisplacementUv; -varying vec3 displacement; - -void main() { - vUv = uv; - - vDisplacementUv = vec2(uv1.x, 1.0 - currentFrame / totalFrames); - - vec3 offset = texture2D(tDisplacement, vDisplacementUv).xzy; - displacement = offset; - - offset *= offsetScale; - vec3 newPosition = position + offset; - - gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0); -} From 0001366da88d8ab90997236fb6b426bb893a4e2e Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 16:34:55 -0300 Subject: [PATCH 11/21] feat: migrate material-postprocessing from GLSL to TSL Convert full-screen post-processing shader to NodeMaterial + TSL. Bloom uses TSL Loop() with Vogel disk sampling, ACES tonemap via manual matrix multiply, branchless alpha/reveal logic. Removed dead uniforms and functions. Widened revealOpacityMaterials type to accept Material. Co-Authored-By: Claude Opus 4.5 --- src/components/map/bakes.tsx | 3 +- .../postprocessing/post-processing.tsx | 53 +-- .../material-postprocessing/fragment.glsl | 308 ------------------ src/shaders/material-postprocessing/index.ts | 265 +++++++++++++-- .../material-postprocessing/vertex.glsl | 9 - 5 files changed, 262 insertions(+), 376 deletions(-) delete mode 100644 src/shaders/material-postprocessing/fragment.glsl delete mode 100644 src/shaders/material-postprocessing/vertex.glsl diff --git a/src/components/map/bakes.tsx b/src/components/map/bakes.tsx index dfbebe0f..cc33e8d8 100644 --- a/src/components/map/bakes.tsx +++ b/src/components/map/bakes.tsx @@ -4,6 +4,7 @@ import { useLoader, useThree } from "@react-three/fiber" import { memo, Suspense, useEffect, useMemo } from "react" import { Group, + Material, Mesh, NearestFilter, NoColorSpace, @@ -175,7 +176,7 @@ const useBakes = (): Record => { /** Attach a material to this array and it will change its uOpacity onLoad */ export const revealOpacityMaterials = new Set< - ShaderMaterial | RawShaderMaterial + ShaderMaterial | RawShaderMaterial | Material >() const Bakes = () => { diff --git a/src/components/postprocessing/post-processing.tsx b/src/components/postprocessing/post-processing.tsx index 03625b41..7cc9ac5c 100644 --- a/src/components/postprocessing/post-processing.tsx +++ b/src/components/postprocessing/post-processing.tsx @@ -34,7 +34,10 @@ const Inner = ({ const firstRender = useRef(true) const { isMobile } = useDeviceDetect() - const material = useMemo(() => createPostProcessingMaterial(), []) + const { material, uniforms } = useMemo( + () => createPostProcessingMaterial(), + [] + ) useEffect(() => { revealOpacityMaterials.add(material) @@ -112,25 +115,25 @@ const Inner = ({ useFrameCallback(() => { if (!hasChanged.current) { - material.uniforms.uContrast.value = targets.contrast.get() - material.uniforms.uBrightness.value = targets.brightness.get() - material.uniforms.uExposure.value = targets.exposure.get() - material.uniforms.uGamma.value = targets.gamma.get() - material.uniforms.uVignetteRadius.value = targets.vignetteRadius.get() - material.uniforms.uVignetteSpread.value = targets.vignetteSpread.get() - material.uniforms.uBloomStrength.value = targets.bloomStrength.get() - material.uniforms.uBloomRadius.value = targets.bloomRadius.get() - material.uniforms.uBloomThreshold.value = targets.bloomThreshold.get() + uniforms.uContrast.value = targets.contrast.get() + uniforms.uBrightness.value = targets.brightness.get() + uniforms.uExposure.value = targets.exposure.get() + uniforms.uGamma.value = targets.gamma.get() + uniforms.uVignetteRadius.value = targets.vignetteRadius.get() + uniforms.uVignetteSpread.value = targets.vignetteSpread.get() + uniforms.uBloomStrength.value = targets.bloomStrength.get() + uniforms.uBloomRadius.value = targets.bloomRadius.get() + uniforms.uBloomThreshold.value = targets.bloomThreshold.get() } else { - material.uniforms.uContrast.value = basics.contrast - material.uniforms.uBrightness.value = basics.brightness - material.uniforms.uExposure.value = basics.exposure - material.uniforms.uGamma.value = basics.gamma - material.uniforms.uVignetteRadius.value = vignette.radius - material.uniforms.uVignetteSpread.value = vignette.spread - material.uniforms.uBloomStrength.value = bloom.strength - material.uniforms.uBloomRadius.value = bloom.radius - material.uniforms.uBloomThreshold.value = bloom.threshold + uniforms.uContrast.value = basics.contrast + uniforms.uBrightness.value = basics.brightness + uniforms.uExposure.value = basics.exposure + uniforms.uGamma.value = basics.gamma + uniforms.uVignetteRadius.value = vignette.radius + uniforms.uVignetteSpread.value = vignette.spread + uniforms.uBloomStrength.value = bloom.strength + uniforms.uBloomRadius.value = bloom.radius + uniforms.uBloomThreshold.value = bloom.threshold } }) @@ -140,13 +143,13 @@ const Inner = ({ useEffect(() => { const controller = new AbortController() - material.uniforms.resolution.value.set(screenWidth, screenHeight) - material.uniforms.uPixelRatio.value = Math.min(window.devicePixelRatio, 2) + uniforms.resolution.value.set(screenWidth, screenHeight) + uniforms.uPixelRatio.value = Math.min(window.devicePixelRatio, 2) - material.uniforms.uActiveBloom.value = isMobile ? 0 : 1 + uniforms.uActiveBloom.value = isMobile ? 0 : 1 - material.uniforms.uMainTexture.value = mainTexture - material.uniforms.uDepthTexture.value = depthTexture + uniforms.uMainTexture.value = mainTexture + uniforms.uDepthTexture.value = depthTexture return () => controller.abort() @@ -154,7 +157,7 @@ const Inner = ({ }, [mainTexture, depthTexture, isMobile, screenWidth, screenHeight]) useFrameCallback((_, __, elapsedTime) => { - material.uniforms.uTime.value = elapsedTime + uniforms.uTime.value = elapsedTime }) return ( diff --git a/src/shaders/material-postprocessing/fragment.glsl b/src/shaders/material-postprocessing/fragment.glsl deleted file mode 100644 index e8a024f9..00000000 --- a/src/shaders/material-postprocessing/fragment.glsl +++ /dev/null @@ -1,308 +0,0 @@ -precision highp float; - -const int SAMPLE_COUNT = 24; -const vec3 LUMINANCE_FACTORS = vec3(0.2126, 0.7152, 0.0722); - -uniform sampler2D uMainTexture; -uniform sampler2D uDepthTexture; -uniform vec2 resolution; -uniform float uPixelRatio; -uniform float uTolerance; - -uniform float uOpacity; - -// Basics -uniform float uGamma; -uniform float uContrast; -uniform float uExposure; -uniform float uBrightness; - -// Vignette -uniform float uVignetteRadius; -uniform float uVignetteSpread; -uniform float uVignetteStrength; -uniform float uVignetteSoftness; - -// Bloom -uniform float uBloomStrength; -uniform float uBloomRadius; -uniform float uBloomThreshold; -uniform float uActiveBloom; - -// Color mask (basketball) -uniform vec2 uEllipseCenter; -uniform vec2 uEllipseSize; -uniform float uEllipseSoftness; -uniform bool uDebugEllipse; - -// 404 -uniform float u404Transition; - -uniform float uTime; - -const float GOLDEN_ANGLE = 2.399963229728653; -const float DENSITY = 0.9; -const float OPACITY_SCANLINE = 0.24; -const float OPACITY_NOISE = 0.01; - -// Additional precalculated constants -const float PI_2 = 6.28318530718; // 2*PI - -varying vec2 vUv; - -float getVignetteFactor(vec2 uv) { - vec2 center = vec2(0.5, 0.5); - float radius = uVignetteRadius; - float spread = uVignetteSpread; - - float vignetteFactor = - 1.0 - smoothstep(radius, radius - spread, length(uv - center)); - return vignetteFactor; -} - -float hash(vec2 p) { - p = fract(p * vec2(123.34, 456.21)); - p += dot(p, p + 456.21); - return fract(p.x * p.y); -} - -vec2 vogelDiskSample(int sampleIndex, int samplesCount, float phi) { - float invSamplesCount = 1.0 / sqrt(float(samplesCount)); - - float r = sqrt(float(sampleIndex) + 0.5) * invSamplesCount; - float theta = float(sampleIndex) * GOLDEN_ANGLE + phi; - - return vec2(r * cos(theta), r * sin(theta)); -} - -vec3 invertedGamma(vec3 color, float gamma) { - return pow(color, vec3(gamma)); -} - -vec3 exposureToneMap(vec3 color, float exposure) { - return vec3(1.0) - exp(-color * exposure); -} - -vec3 contrast(vec3 color, float contrast) { - return (color - 0.5) * contrast + 0.5; -} - -// Optimize the RRTAndODTFit function to reduce operations -vec3 RRTAndODTFit(vec3 v) { - // Precalculated constants - const float c1 = 0.0245786; - const float c2 = -0.000090537; - const float c3 = 0.983729; - const float c4 = 0.432951; - const float c5 = 0.238081; - - // Operations organized to minimize calculations - vec3 v2 = v * v; - vec3 a = v * c1 + v2 + c2; - vec3 b = v * c4 + v2 * c3 + c5; - - return a / b; -} - -vec3 ACESFilmicToneMapping(vec3 color) { - // Precalculated constants for exposure - const float EXPOSURE_ADJUST = 1.0 / 0.6; - - // Transposed matrices to optimize multiplications - // sRGB => XYZ => D65_2_D60 => AP1 => RRT_SAT - const mat3 ACESInputMat = mat3( - 0.59719, 0.35458, 0.04823, - 0.076 , 0.90834, 0.01566, - 0.0284 , 0.13383, 0.83777 - ); - - // ODT_SAT => XYZ => D60_2_D65 => sRGB - const mat3 ACESOutputMat = mat3( - 1.60475, -0.53108, -0.07367, - -0.10208, 1.10813, -0.00605, - -0.00327, -0.07276, 1.07602 - ); - - // Apply adjusted exposure - color *= uExposure * EXPOSURE_ADJUST; - - // Optimized matrix multiplication (skip multiplications by 0) - vec3 colorTransformed; - - // First transformation: ACESInputMat * color - colorTransformed.r = - ACESInputMat[0][0] * color.r + - ACESInputMat[0][1] * color.g + - ACESInputMat[0][2] * color.b; - colorTransformed.g = - ACESInputMat[1][0] * color.r + - ACESInputMat[1][1] * color.g + - ACESInputMat[1][2] * color.b; - colorTransformed.b = - ACESInputMat[2][0] * color.r + - ACESInputMat[2][1] * color.g + - ACESInputMat[2][2] * color.b; - - // Apply RRT and ODT - colorTransformed = RRTAndODTFit(colorTransformed); - - // Second transformation: ACESOutputMat * colorTransformed - vec3 outputColor; - outputColor.r = - ACESOutputMat[0][0] * colorTransformed.r + - ACESOutputMat[0][1] * colorTransformed.g + - ACESOutputMat[0][2] * colorTransformed.b; - outputColor.g = - ACESOutputMat[1][0] * colorTransformed.r + - ACESOutputMat[1][1] * colorTransformed.g + - ACESOutputMat[1][2] * colorTransformed.b; - outputColor.b = - ACESOutputMat[2][0] * colorTransformed.r + - ACESOutputMat[2][1] * colorTransformed.g + - ACESOutputMat[2][2] * colorTransformed.b; - - // Clamp to [0, 1] - return clamp(outputColor, 0.0, 1.0); -} - -vec3 tonemap(vec3 color) { - // Apply brightness - here we continue using direct multiplication - color.rgb *= uBrightness; - - // Apply contrast - color = contrast(color, uContrast); - - // Apply inverted gamma correction - color = invertedGamma(color, uGamma); - - // Apply optimized ACES Filmic Tone Mapping - color = ACESFilmicToneMapping(color); - - return color; -} - -float random(vec2 st) { - // Precalculated constants - const vec2 k = vec2(12.9898, 78.233); - const float m = 43758.5453123; - - // Optimized calculation - float dot_product = dot(st.xy, k); - return fract(sin(dot_product) * m); -} - -float blend(const float x, const float y) { - // Use mix with step to reduce branching - return mix(2.0 * x * y, 1.0 - 2.0 * (1.0 - x) * (1.0 - y), step(0.5, x)); -} - -vec3 blend(const vec3 x, const vec3 y, const float opacity) { - // Using the optimized blend version - vec3 z = vec3(blend(x.r, y.r), blend(x.g, y.g), blend(x.b, y.b)); - - // Optimization: direct use of lerp/mix instead of manual operations - return mix(x, z, opacity); -} - -void main() { - // Precalculate frequently used values - float checkerSize = 2.0 * uPixelRatio; - vec2 checkerPos = floor(gl_FragCoord.xy / checkerSize); - float checkerPattern = mod(checkerPos.x + checkerPos.y, 2.0); - - // Precalculate resolution divisions - vec2 halfResolution = resolution / 2.0; - vec2 eighthResolution = resolution / 8.0; - vec2 invResolution = 1.0 / resolution; - vec2 uvOffset = invResolution; - - // Calculate pixelated coordinates only once - vec2 pixelatedUv = floor(vUv * halfResolution) * 2.0 / resolution; - vec2 pixelatedUvEighth = floor(vUv * eighthResolution) * 8.0 / resolution; - - // Optimized texture reading - vec4 baseColorSample = texture2D(uMainTexture, vUv); - vec3 color = baseColorSample.rgb; - - // Apply tonemap only once for the main color - color = tonemap(color); - - // Calculate alpha and check if we need the pixelated texture - float alpha = 1.0; - - // Optimize opacity check - if (uOpacity < 0.001) { - // If opacity is almost zero, directly set alpha to 0 - alpha = 0.0; - } else { - float reveal = 1.0 - uOpacity; - reveal = clamp(reveal, 0.0, 1.0); - reveal = reveal * reveal * reveal * reveal; - - // Only calculate baseBrightness if necessary - if (reveal > 0.0) { - vec4 basePixelatedSample = texture2D(uMainTexture, pixelatedUvEighth); - float baseBrightness = dot( - tonemap(basePixelatedSample.rgb), - LUMINANCE_FACTORS - ); - - if (baseBrightness < reveal) { - alpha = 0.0; - } - } - } - - // The bloom calculation remains exactly the same - vec3 bloomColor = vec3(0.0); - - if (uBloomStrength > 0.001 && checkerPattern > 0.5 && uActiveBloom > 0.5) { - // Apply bloom effect only when bloom strength is significant and on checker pattern - vec3 bloom = vec3(0.0); - float totalWeight = 0.0; - float phi = hash(pixelatedUv) * PI_2; // Random rotation angle, using precalculated constant - - // Precalculate the division of uBloomRadius by resolution - vec2 bloomRadiusScaled = uBloomRadius * invResolution; - - for (int i = 1; i < SAMPLE_COUNT; i++) { - vec2 sampleOffset = - vogelDiskSample(i, SAMPLE_COUNT, phi) * bloomRadiusScaled; - float dist = length(sampleOffset); - - // Gaussian-like falloff - avoid division when dist is very small - float weight = dist > 0.001 ? 1.0 / dist : 1000.0; - - // Sample color at offset position - use precalculated coordinates - vec3 sampleColor = texture2D( - uMainTexture, - pixelatedUv + sampleOffset + uvOffset - ).rgb; - - // Only add to bloom if brightness is above threshold - float brightness = dot(sampleColor, LUMINANCE_FACTORS); - - // Avoid conditional branching which can be costly on GPUs - // Use a version of step() to simulate the if-statement - float shouldAdd = step(uBloomThreshold, brightness); - totalWeight += weight; - bloom += sampleColor * weight * shouldAdd; - } - - // Normalize bloom and apply strength - float safeWeight = max(totalWeight, 0.0001); - bloomColor = bloom / safeWeight * uBloomStrength; - } - - // Add bloom to result with strength control - color += bloomColor; - color = clamp(color, 0.0, 1.0); - - // The vignette application remains exactly the same - float vignetteFactor = getVignetteFactor(vUv); - color = mix(color, vec3(0.0), vignetteFactor); - - gl_FragColor = vec4(color, alpha); - - #include -} diff --git a/src/shaders/material-postprocessing/index.ts b/src/shaders/material-postprocessing/index.ts index 874f068d..3492e375 100644 --- a/src/shaders/material-postprocessing/index.ts +++ b/src/shaders/material-postprocessing/index.ts @@ -1,36 +1,235 @@ -import { ShaderMaterial, Vector2 } from "three" +import { Texture, Vector2 } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec2, + vec3, + vec4, + uniform, + uv, + texture, + screenCoordinate, + sin, + cos, + dot, + fract, + floor, + mod, + smoothstep, + step, + mix, + clamp, + max, + pow, + sqrt, + length, + select, + Loop, + int +} from "three/tsl" -import fragmentShader from "./fragment.glsl" -import vertexShader from "./vertex.glsl" +const SAMPLE_COUNT = 24 +const GOLDEN_ANGLE = 2.399963229728653 +const PI_2 = 6.28318530718 -export const createPostProcessingMaterial = () => - new ShaderMaterial({ - uniforms: { - uMainTexture: { value: null }, - uDepthTexture: { value: null }, - aspect: { value: 1 }, - resolution: { value: new Vector2(1, 1) }, - uPixelRatio: { value: 1 }, - uTime: { value: 0.0 }, - uOpacity: { value: 1.0 }, - - uActiveBloom: { value: 1 }, - - // Basics - uContrast: { value: 1 }, - uBrightness: { value: 1 }, - uExposure: { value: 1 }, - uGamma: { value: 1 }, - - // Vignette - uVignetteRadius: { value: 0.9 }, - uVignetteSpread: { value: 0.5 }, - - // Bloom - uBloomStrength: { value: 1 }, - uBloomRadius: { value: 1 }, - uBloomThreshold: { value: 1 } - }, - vertexShader, - fragmentShader +export const createPostProcessingMaterial = () => { + const uMainTexture = texture(new Texture()) + const uDepthTexture = texture(new Texture()) + const uResolution = uniform(new Vector2(1, 1)) + const uPixelRatio = uniform(1) + const uTime = uniform(0) + const uOpacity = uniform(1) + const uActiveBloom = uniform(1) + const uContrast = uniform(1) + const uBrightness = uniform(1) + const uExposure = uniform(1) + const uGamma = uniform(1) + const uVignetteRadius = uniform(0.9) + const uVignetteSpread = uniform(0.5) + const uBloomStrength = uniform(1) + const uBloomRadius = uniform(1) + const uBloomThreshold = uniform(1) + + const material = new NodeMaterial() + material.transparent = true + + // --- Helper Fns --- + + const hashFn = /* @__PURE__ */ Fn(([p]: [any]) => { + const hp = fract(p.mul(vec2(123.34, 456.21))).toVar() + hp.addAssign(dot(hp, hp.add(456.21))) + return fract(hp.x.mul(hp.y)) + }) + + const vogelDiskSampleFn = /* @__PURE__ */ Fn( + ([sampleIndex, samplesCount, phi]: [any, any, any]) => { + const invSamplesCount = float(1.0).div(sqrt(float(samplesCount))) + const r = sqrt(float(sampleIndex).add(0.5)).mul(invSamplesCount) + const theta = float(sampleIndex).mul(GOLDEN_ANGLE).add(phi) + return vec2(r.mul(cos(theta)), r.mul(sin(theta))) + } + ) + + const contrastFn = /* @__PURE__ */ Fn(([color, c]: [any, any]) => { + return color.sub(0.5).mul(c).add(0.5) }) + + const RRTAndODTFitFn = /* @__PURE__ */ Fn(([v]: [any]) => { + const v2 = v.mul(v) + const a = v.mul(0.0245786).add(v2).add(-0.000090537) + const b = v.mul(0.432951).add(v2.mul(0.983729)).add(0.238081) + return a.div(b) + }) + + const ACESFilmicFn = /* @__PURE__ */ Fn(([color]: [any]) => { + const c = color.mul(uExposure).mul(1.0 / 0.6).toVar() + + // ACESInputMat * color + const ct = vec3( + float(0.59719).mul(c.r).add(float(0.35458).mul(c.g)).add(float(0.04823).mul(c.b)), + float(0.076).mul(c.r).add(float(0.90834).mul(c.g)).add(float(0.01566).mul(c.b)), + float(0.0284).mul(c.r).add(float(0.13383).mul(c.g)).add(float(0.83777).mul(c.b)) + ).toVar() + + ct.assign(RRTAndODTFitFn(ct)) + + // ACESOutputMat * colorTransformed + return clamp( + vec3( + float(1.60475).mul(ct.r).add(float(-0.53108).mul(ct.g)).add(float(-0.07367).mul(ct.b)), + float(-0.10208).mul(ct.r).add(float(1.10813).mul(ct.g)).add(float(-0.00605).mul(ct.b)), + float(-0.00327).mul(ct.r).add(float(-0.07276).mul(ct.g)).add(float(1.07602).mul(ct.b)) + ), + 0.0, + 1.0 + ) + }) + + const tonemapFn = /* @__PURE__ */ Fn(([color]: [any]) => { + const c = color.mul(uBrightness).toVar() + c.assign(contrastFn(c, uContrast)) + c.assign(pow(c, vec3(uGamma))) // inverted gamma + c.assign(ACESFilmicFn(c)) + return c + }) + + const getVignetteFactorFn = /* @__PURE__ */ Fn(([uvCoord]: [any]) => { + const center = vec2(0.5, 0.5) + return float(1.0).sub( + smoothstep( + uVignetteRadius, + uVignetteRadius.sub(uVignetteSpread), + length(uvCoord.sub(center)) + ) + ) + }) + + // --- Main post-processing computation --- + + const LUMINANCE_FACTORS = vec3(0.2126, 0.7152, 0.0722) + + const postProcessResult = Fn(() => { + const vUv = uv() + + // Checker pattern using screen coordinates + const checkerSize = float(2.0).mul(uPixelRatio) + const checkerPos = floor(screenCoordinate.xy.div(checkerSize)) + const checkerPattern = mod(checkerPos.x.add(checkerPos.y), 2.0) + + // Resolution helpers + const halfResolution = uResolution.div(2.0) + const eighthResolution = uResolution.div(8.0) + const invResolution = vec2(1.0, 1.0).div(uResolution) + + // Pixelated UVs + const pixelatedUv = floor(vUv.mul(halfResolution)).mul(2.0).div(uResolution) + const pixelatedUvEighth = floor(vUv.mul(eighthResolution)).mul(8.0).div(uResolution) + + // Main texture sample + tonemap + const baseColorSample = uMainTexture.uv(vUv) + const color = tonemapFn(baseColorSample.rgb).toVar() + + // Alpha / reveal logic (branchless) + const opacityOk = step(float(0.001), uOpacity) // 1 when opacity >= 0.001 + const reveal = clamp(float(1.0).sub(uOpacity), 0.0, 1.0) + const revealPow = reveal.mul(reveal).mul(reveal).mul(reveal) + const basePixelatedSample = uMainTexture.uv(pixelatedUvEighth) + const baseBrightness = dot(tonemapFn(basePixelatedSample.rgb), LUMINANCE_FACTORS) + // revealHides = 1 when reveal is active AND brightness < reveal threshold + const revealHides = step(float(0.001), revealPow).mul( + float(1.0).sub(step(revealPow, baseBrightness)) + ) + const alpha = opacityOk.mul(float(1.0).sub(revealHides)) + + // Bloom (Vogel disk sampling) + const bloomActive = step(float(0.001), uBloomStrength) + .mul(step(float(0.5), checkerPattern)) + .mul(step(float(0.5), uActiveBloom)) + + const bloom = vec3(0.0, 0.0, 0.0).toVar() + const totalWeight = float(0.0).toVar() + const phi = hashFn(pixelatedUv).mul(PI_2) + const bloomRadiusScaled = uBloomRadius.mul(invResolution) + + Loop(SAMPLE_COUNT - 1, ({ i }: { i: any }) => { + const idx = i.add(1) + const sampleOffset = vogelDiskSampleFn( + idx, + int(SAMPLE_COUNT), + phi + ).mul(bloomRadiusScaled) + const dist = length(sampleOffset) + + const weight = select( + dist.greaterThan(0.001), + float(1.0).div(dist), + float(1000.0) + ) + + const sampleColor = uMainTexture + .uv(pixelatedUv.add(sampleOffset).add(invResolution)) + .rgb + const brightness = dot(sampleColor, LUMINANCE_FACTORS) + const shouldAdd = step(uBloomThreshold, brightness) + + totalWeight.addAssign(weight) + bloom.addAssign(sampleColor.mul(weight).mul(shouldAdd)) + }) + + const safeWeight = max(totalWeight, float(0.0001)) + color.addAssign(bloom.div(safeWeight).mul(uBloomStrength).mul(bloomActive)) + color.assign(clamp(color, 0.0, 1.0)) + + // Vignette + const vignetteFactor = getVignetteFactorFn(vUv) + color.assign(mix(color, vec3(0.0, 0.0, 0.0), vignetteFactor)) + + return vec4(color, alpha) + })() + + material.colorNode = postProcessResult.rgb + material.opacityNode = postProcessResult.a + + return { + material, + uniforms: { + uMainTexture, + uDepthTexture, + resolution: uResolution, + uPixelRatio, + uTime, + uOpacity, + uActiveBloom, + uContrast, + uBrightness, + uExposure, + uGamma, + uVignetteRadius, + uVignetteSpread, + uBloomStrength, + uBloomRadius, + uBloomThreshold + } + } +} diff --git a/src/shaders/material-postprocessing/vertex.glsl b/src/shaders/material-postprocessing/vertex.glsl deleted file mode 100644 index 4519f65c..00000000 --- a/src/shaders/material-postprocessing/vertex.glsl +++ /dev/null @@ -1,9 +0,0 @@ -precision mediump float; - -varying vec2 vUv; - -void main() { - vUv = uv; - gl_Position = - projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0); -} From 0219c02a194c5dd7670b9fe41032553509ab5f43 Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 16:38:02 -0300 Subject: [PATCH 12/21] feat: migrate material-solid-reveal from GLSL to TSL Replace RawShaderMaterial+GLSL3 with NodeMaterial+TSL. Uses mx_noise_float for 3D/4D Perlin noise (4D approximated via time-shifted 3D), positionWorld for world coords, camera matrices for worldToUv projection. Compat layer preserves consumer API. Co-Authored-By: Claude Opus 4.5 --- .../material-solid-reveal/fragment.glsl | 138 ----------------- src/shaders/material-solid-reveal/index.ts | 142 +++++++++++++++--- src/shaders/material-solid-reveal/vertex.glsl | 12 -- 3 files changed, 123 insertions(+), 169 deletions(-) delete mode 100644 src/shaders/material-solid-reveal/fragment.glsl delete mode 100644 src/shaders/material-solid-reveal/vertex.glsl diff --git a/src/shaders/material-solid-reveal/fragment.glsl b/src/shaders/material-solid-reveal/fragment.glsl deleted file mode 100644 index ac33bb84..00000000 --- a/src/shaders/material-solid-reveal/fragment.glsl +++ /dev/null @@ -1,138 +0,0 @@ -precision highp float; - -in vec3 vWorldPosition; -in float vDepth; - -out vec4 fragColor; - -uniform float uTime; -uniform float uReveal; -uniform float uScreenReveal; -uniform vec2 uScreenSize; -uniform sampler2D uFlowTexture; -uniform vec3 cameraPosition; - -uniform mat4 viewMatrix; -uniform mat4 projectionMatrix; - -#pragma glslify: cnoise3 = require(glsl-noise/classic/3d) -#pragma glslify: cnoise4 = require(glsl-noise/classic/4d) - -float isEdge(vec3 p, float voxelSize) { - float edgeLimit = 0.94; - float zEdge = float(abs(p.z) * voxelSize * 2.0 > edgeLimit); - float yEdge = float(abs(p.y) * voxelSize * 2.0 > edgeLimit); - float xEdge = float(abs(p.x) * voxelSize * 2.0 > edgeLimit); - - float totalEdge = xEdge + yEdge + zEdge; - return totalEdge >= 1.0 - ? 1.0 - : 0.0; -} - -struct VoxelData { - float edgeFactor; - float fillFactor; - vec3 center; - float size; - float noiseBig; - float noiseSmall; -}; - -VoxelData getVoxel( - vec3 pWorld, - float voxelSize, - float noiseBigScale, - float noiseSmallScale -) { - vec3 voxelCenter = round(pWorld * voxelSize) / voxelSize; - - vec3 noiseP = voxelCenter; - float noiseBig = cnoise4(vec4( noiseP * noiseBigScale, uTime * 0.05)); - float noiseSmall = cnoise3( noiseP * noiseSmallScale); - // float edgeFactor = isEdge(voxelCenter - pWorld, voxelSize); - float edgeFactor = 0.0; - float fillFactor = 1.0 - edgeFactor; - - return VoxelData( - edgeFactor, - fillFactor, - voxelCenter, - voxelSize, - noiseBig, - noiseSmall - ); -} - -float valueRemap(float value, float inMin, float inMax, float outMin, float outMax) { - return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin); -} - -vec2 worldToUv(vec3 p) { - vec4 clipPos = projectionMatrix * viewMatrix * vec4(p, 1.0); - vec3 ndcPos = clipPos.xyz / clipPos.w; - vec2 screenUv = (ndcPos.xy + 1.0) * 0.5; - return screenUv; -} - -void main() { - - vec3 p = vWorldPosition + vec3(0.0, 0.11, 0.1); - - VoxelData voxel = getVoxel(p, 15., 0.2, 20.0); - - if(voxel.noiseSmall * 0.5 + 0.5 < uScreenReveal) { - discard; - return; - } - - float edgeFactor = (1. - voxel.edgeFactor); - - float colorBump = voxel.noiseBig * 1.5; - colorBump = clamp(colorBump, -1., 1.); - // add small offset - colorBump -= voxel.noiseSmall * 0.1; - // shift over time - colorBump += uTime * 0.2; - // make it loop repetitions - colorBump = fract(colorBump * 1.); - // remap so that it leaves a trail - colorBump = valueRemap(colorBump, 0.0, 0.1, 1.0, 0.0); - colorBump = clamp(colorBump, 0.0, 1.0); - colorBump = pow(colorBump, 2.); - // colorBump *= edgeFactor; - colorBump *= uReveal > pow(voxel.noiseSmall * 0.5 + 0.5, 2.) ? 1.0 : 0.0; - colorBump *= 0.4; - - //debug flow - // fragColor = vec4(vec3(0.0, 0.0, 0.0), 1.0); - - - // vec2 screenUv = ( gl_FragCoord.xy * 2.0 - uScreenSize ) / uScreenSize; - // screenUv *= vec2(0.5, 0.5); - // screenUv += vec2(0.5); - - vec2 screenUv = worldToUv(voxel.center); - - vec4 flowColor = texture(uFlowTexture, screenUv); - - - float distanceToCamera = distance(cameraPosition, voxel.center); - - float flowCenter = flowColor.r; - float flowRadius = flowColor.g; - - float flowSdf = abs(distanceToCamera - flowCenter); - flowSdf = abs(flowSdf - flowRadius); - flowSdf = 1. - flowSdf; - flowSdf -= voxel.noiseSmall; - flowSdf = valueRemap(flowSdf, 0.5, 1., 0., 1.); - flowSdf = clamp(flowSdf, 0.0, 1.0); - flowSdf = pow(flowSdf, 4.); - - - fragColor.rgb = vec3(clamp(flowSdf + colorBump, 0., 1.)); - fragColor.a = 1.0; - return; - -} diff --git a/src/shaders/material-solid-reveal/index.ts b/src/shaders/material-solid-reveal/index.ts index 4f6e85f3..3de9665c 100644 --- a/src/shaders/material-solid-reveal/index.ts +++ b/src/shaders/material-solid-reveal/index.ts @@ -1,20 +1,124 @@ -import { GLSL3, RawShaderMaterial, Vector2 } from "three" - -import fragmentShader from "./fragment.glsl" -import vertexShader from "./vertex.glsl" - -export const createSolidRevealMaterial = () => - new RawShaderMaterial({ - glslVersion: GLSL3, - uniforms: { - uTime: { value: 0.0 }, - uReveal: { value: 0.0 }, - uScreenReveal: { value: 0.0 }, - uFlowTexture: { value: null }, - uScreenSize: { value: new Vector2(1, 1) }, - uNear: { value: 0.1 }, - uFar: { value: 100 } - }, - vertexShader, - fragmentShader +import { Texture, Vector2 } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec3, + vec4, + uniform, + texture, + positionWorld, + cameraPosition, + cameraViewMatrix, + cameraProjectionMatrix, + mx_noise_float, + round, + abs, + clamp, + fract, + pow, + step, + distance +} from "three/tsl" + +export const createSolidRevealMaterial = () => { + const uTime = uniform(0) + const uReveal = uniform(0) + const uScreenReveal = uniform(0) + const uFlowTexture = texture(new Texture()) + const uScreenSize = uniform(new Vector2(1, 1)) + const uNear = uniform(0.1) + const uFar = uniform(100) + + const material = new NodeMaterial() + + // worldToUv: project world position to screen UV via camera matrices + const worldToUvFn = /* @__PURE__ */ Fn(([p]: [any]) => { + const clipPos = cameraProjectionMatrix.mul(cameraViewMatrix).mul( + vec4(p, 1.0) + ) + const ndcPos = clipPos.xyz.div(clipPos.w) + return ndcPos.xy.add(1.0).mul(0.5) }) + + // valueRemap helper + const valueRemapFn = /* @__PURE__ */ Fn( + ([value, inMin, inMax, outMin, outMax]: [any, any, any, any, any]) => { + return float(outMin).add( + float(outMax) + .sub(outMin) + .mul(float(value).sub(inMin)) + .div(float(inMax).sub(inMin)) + ) + } + ) + + material.colorNode = Fn(() => { + // World position with offset + const p = positionWorld.add(vec3(0.0, 0.11, 0.1)) + + // Voxel computation: snap to grid + const voxelSize = float(15.0) + const voxelCenter = round(p.mul(voxelSize)).div(voxelSize) + + // 3D noise (equivalent to cnoise3) + const noiseSmall = mx_noise_float(voxelCenter.mul(20.0)) + + // Approximate 4D noise via time-shifted 3D noise (equivalent to cnoise4) + const noiseBig = mx_noise_float( + voxelCenter.mul(0.2).add(vec3(0, 0, uTime.mul(0.05))) + ) + + // Screen reveal discard + noiseSmall.mul(0.5).add(0.5).lessThan(uScreenReveal).discard() + + // Color bump computation + const colorBump = noiseBig.mul(1.5).toVar() + colorBump.assign(clamp(colorBump, -1.0, 1.0)) + colorBump.subAssign(noiseSmall.mul(0.1)) + colorBump.addAssign(uTime.mul(0.2)) + colorBump.assign(fract(colorBump)) + colorBump.assign(valueRemapFn(colorBump, 0.0, 0.1, 1.0, 0.0)) + colorBump.assign(clamp(colorBump, 0.0, 1.0)) + colorBump.assign(pow(colorBump, float(2.0))) + // Branchless reveal condition + colorBump.mulAssign( + step(pow(noiseSmall.mul(0.5).add(0.5), float(2.0)), uReveal) + ) + colorBump.mulAssign(0.4) + + // Project voxel center to screen UV for flow texture sampling + const screenUv = worldToUvFn(voxelCenter) + const flowColor = uFlowTexture.uv(screenUv) + + // Flow SDF computation + const distToCamera = distance(cameraPosition, voxelCenter) + const flowCenter = flowColor.r + const flowRadius = flowColor.g + + const flowSdf = abs(distToCamera.sub(flowCenter)).toVar() + flowSdf.assign(abs(flowSdf.sub(flowRadius))) + flowSdf.assign(float(1.0).sub(flowSdf)) + flowSdf.subAssign(noiseSmall) + flowSdf.assign(valueRemapFn(flowSdf, 0.5, 1.0, 0.0, 1.0)) + flowSdf.assign(clamp(flowSdf, 0.0, 1.0)) + flowSdf.assign(pow(flowSdf, float(4.0))) + + return vec3(clamp(flowSdf.add(colorBump), 0.0, 1.0)) + })() + + material.opacityNode = float(1.0) + + // Compatibility layer: consumer accesses material.uniforms.X.value + ;(material as any).uniforms = { + uTime, + uReveal, + uScreenReveal, + uFlowTexture, + uScreenSize, + uNear, + uFar + } + + return material +} diff --git a/src/shaders/material-solid-reveal/vertex.glsl b/src/shaders/material-solid-reveal/vertex.glsl deleted file mode 100644 index 9c529d97..00000000 --- a/src/shaders/material-solid-reveal/vertex.glsl +++ /dev/null @@ -1,12 +0,0 @@ -in vec3 position; - -uniform mat4 modelMatrix; -uniform mat4 viewMatrix; -uniform mat4 projectionMatrix; - -out vec3 vWorldPosition; - -void main() { - vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; - gl_Position = projectionMatrix * viewMatrix * vec4(vWorldPosition, 1.0); -} From 9b07153192a0c758fe31241221580597f9125e46 Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 16:47:12 -0300 Subject: [PATCH 13/21] feat: migrate material-flow from GLSL to TSL Replace RawShaderMaterial with NodeMaterial + TSL for the flow feedback simulation shader. Convert neighbor sampling with branchless step/mix pattern, 2D noise via mx_noise_float, and Proxy-based uniforms compat layer for consumer compatibility. Also removes leftover steam GLSL files. Co-Authored-By: Claude Opus 4.5 --- src/shaders/material-flow/fragment.glsl | 96 -------------- src/shaders/material-flow/index.ts | 155 ++++++++++++++++++++--- src/shaders/material-flow/vertex.glsl | 16 --- src/shaders/material-steam/fragment.glsl | 60 --------- src/shaders/material-steam/vertex.glsl | 27 ---- 5 files changed, 138 insertions(+), 216 deletions(-) delete mode 100644 src/shaders/material-flow/fragment.glsl delete mode 100644 src/shaders/material-flow/vertex.glsl delete mode 100644 src/shaders/material-steam/fragment.glsl delete mode 100644 src/shaders/material-steam/vertex.glsl diff --git a/src/shaders/material-flow/fragment.glsl b/src/shaders/material-flow/fragment.glsl deleted file mode 100644 index 031deafe..00000000 --- a/src/shaders/material-flow/fragment.glsl +++ /dev/null @@ -1,96 +0,0 @@ -precision highp float; - -in vec2 vUv; -in vec2 vFlowSize; -in vec2 dxy; - -out vec4 fragColor; - -uniform vec2 uMousePosition; -uniform float uMouseDepth; -uniform float uRenderCount; -uniform sampler2D uFeedbackTexture; -uniform int uFrame; -uniform float uMouseMoving; - -#pragma glslify: cnoise2 = require(glsl-noise/classic/2d) - -float circleSdf(vec2 pos, vec2 center, float radius) { - return length(pos - center) - radius; -} - -float valueRemap(float value, float min, float max, float newMin, float newMax) { - return newMin + (newMax - newMin) * (value - min) / (max - min); -} - -const int gridSize = 3; - - -// FLOW CHANNELS -// r - depht of pointer -// g - growth of wave -// b - power of wave - -vec4 samplePrev(vec2 uv) { - // Convert UV coordinates to pixel coordinates - vec2 resolution = vec2(textureSize(uFeedbackTexture, 0)); - vec2 pixel = uv * resolution; - - vec4 samples[5]; - - // Sample center and neighboring pixels - vec4 p00 = textureLod(uFeedbackTexture, uv, 0.0); - vec4 p10 = textureLod(uFeedbackTexture, uv + vec2(0.0, -1.0) / resolution, 0.0); - vec4 p01 = textureLod(uFeedbackTexture, uv + vec2(-1.0, 0.0) / resolution, 0.0); - vec4 p21 = textureLod(uFeedbackTexture, uv + vec2(1.0, 0.0) / resolution, 0.0); - vec4 p12 = textureLod(uFeedbackTexture, uv + vec2(0.0, 1.0) / resolution, 0.0); - - samples[0] = p00; - samples[1] = p10; - samples[2] = p01; - samples[3] = p21; - samples[4] = p12; - - vec4 finalSample = p00; - bool changed = false; - - for (int i = 1; i < 5; i++) { - if(samples[i].g < finalSample.g && samples[i].g < 1.) { - finalSample = samples[i]; - changed = true; - } - } - finalSample.g += 0.02; - - float noise = cnoise2(pixel); - - if(finalSample.g > 2. + 1. * noise) { - finalSample.g = 1000.; - } - - // Average the samples for a basic diffusion effect - return finalSample; -} - -void main() { - if (uFrame < 3) { - fragColor = vec4(vec3(3., 100000., 0.), 1.0); - return; - } - - vec2 uv = vUv; - vec2 p = vec2(0.5) - uv; - p *= -2.0; - - vec4 expandedSample = samplePrev(uv); - vec3 color = expandedSample.rgb; - - float circle = circleSdf(p, uMousePosition, 0.01); - - if(circle < 0. && uMouseMoving > 0.) { - color.r = uMouseDepth; - color.g = 0.; - } - - fragColor = vec4(color, 1.0); -} diff --git a/src/shaders/material-flow/index.ts b/src/shaders/material-flow/index.ts index acab6b4a..c125c4bb 100644 --- a/src/shaders/material-flow/index.ts +++ b/src/shaders/material-flow/index.ts @@ -1,18 +1,139 @@ -import { GLSL3, RawShaderMaterial, Vector2 } from "three" - -import fragmentShader from "./fragment.glsl" -import vertexShader from "./vertex.glsl" - -export const createFlowMaterial = () => - new RawShaderMaterial({ - glslVersion: GLSL3, - uniforms: { - uFrame: { value: 0 }, - uFeedbackTexture: { value: null }, - uMousePosition: { value: new Vector2(10, 10) }, - uMouseMoving: { value: 0 }, - uMouseDepth: { value: 0 } - }, - vertexShader, - fragmentShader +import { Texture, Vector2 } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec2, + vec3, + vec4, + uniform, + uv, + texture, + step, + mix, + length, + mx_noise_float +} from "three/tsl" + +const FLOW_RESOLUTION = 1024 + +export const createFlowMaterial = () => { + const uFrame = uniform(0) + const uFeedbackTexture = texture(new Texture()) + const uMousePosition = uniform(new Vector2(10, 10)) + const uMouseMoving = uniform(0) + const uMouseDepth = uniform(0) + + const material = new NodeMaterial() + + const invRes = vec2(1.0 / FLOW_RESOLUTION, 1.0 / FLOW_RESOLUTION) + + // samplePrev: sample center + 4 neighbors, find smallest growth + const samplePrevFn = /* @__PURE__ */ Fn(([uvCoord]: [any]) => { + const pixel = uvCoord.mul(FLOW_RESOLUTION) + + const p00 = uFeedbackTexture.uv(uvCoord) + const p10 = uFeedbackTexture.uv( + uvCoord.add(vec2(0.0, -1.0).mul(invRes)) + ) + const p01 = uFeedbackTexture.uv( + uvCoord.add(vec2(-1.0, 0.0).mul(invRes)) + ) + const p21 = uFeedbackTexture.uv( + uvCoord.add(vec2(1.0, 0.0).mul(invRes)) + ) + const p12 = uFeedbackTexture.uv( + uvCoord.add(vec2(0.0, 1.0).mul(invRes)) + ) + + const finalSample = p00.toVar() + + // For each neighbor: replace if neighbor.g < finalSample.g AND neighbor.g < 1.0 + // step(ng, fg - eps) = 1 when fg - eps >= ng (i.e. ng < fg approximately) + // 1 - step(1.0, ng) = 1 when ng < 1.0 + const r1 = step(p10.g, finalSample.g.sub(0.0001)).mul( + float(1.0).sub(step(float(1.0), p10.g)) + ) + finalSample.assign(mix(finalSample, p10, r1)) + + const r2 = step(p01.g, finalSample.g.sub(0.0001)).mul( + float(1.0).sub(step(float(1.0), p01.g)) + ) + finalSample.assign(mix(finalSample, p01, r2)) + + const r3 = step(p21.g, finalSample.g.sub(0.0001)).mul( + float(1.0).sub(step(float(1.0), p21.g)) + ) + finalSample.assign(mix(finalSample, p21, r3)) + + const r4 = step(p12.g, finalSample.g.sub(0.0001)).mul( + float(1.0).sub(step(float(1.0), p12.g)) + ) + finalSample.assign(mix(finalSample, p12, r4)) + + // Increment growth + finalSample.g.addAssign(0.02) + + // 2D noise (use 3D noise with z=0 as cnoise2 approximation) + const noise = mx_noise_float(vec3(pixel.x, pixel.y, float(0.0))) + + // Kill wave if growth exceeds threshold: g > 2.0 + noise → set g = 1000 + const killThreshold = float(2.0).add(noise) + const shouldKill = step(killThreshold, finalSample.g) + finalSample.g.assign(mix(finalSample.g, float(1000.0), shouldKill)) + + return finalSample }) + + const result = Fn(() => { + const vUv = uv() + + // Frame initialization: if uFrame < 3, output init value + // step(3, uFrame) = 1 when uFrame >= 3; isInit = 1 when uFrame < 3 + const isInit = float(1.0).sub(step(float(3), uFrame)) + const initColor = vec3(3.0, 100000.0, 0.0) + + // Normal computation + const p = vec2(0.5, 0.5).sub(vUv).mul(-2.0) + const expandedSample = samplePrevFn(vUv) + const color = expandedSample.rgb.toVar() + + // Mouse circle interaction (branchless) + const circle = length(p.sub(uMousePosition)).sub(0.01) + const circleNeg = float(1.0).sub(step(float(0.0), circle)) + const mouseIsMoving = step(float(0.001), uMouseMoving) + const mouseActive = circleNeg.mul(mouseIsMoving) + + color.r.assign(mix(color.r, uMouseDepth, mouseActive)) + color.g.assign(mix(color.g, float(0.0), mouseActive)) + + return vec4(mix(color, initColor, isInit), 1.0) + })() + + material.colorNode = result.rgb + material.opacityNode = result.a + + // Compatibility layer: consumer accesses material.uniforms.X.value + // Proxy handles `material.uniforms.X = { value: Y }` by forwarding to X.value = Y + ;(material as any).uniforms = new Proxy( + { uFrame, uFeedbackTexture, uMousePosition, uMouseMoving, uMouseDepth }, + { + set(target: any, prop: string | symbol, value: any) { + if ( + prop in target && + value && + typeof value === "object" && + "value" in value + ) { + target[prop].value = value.value + return true + } + return Reflect.set(target, prop, value) + } + } + ) + + return material as NodeMaterial & { + uniforms: Record + } +} diff --git a/src/shaders/material-flow/vertex.glsl b/src/shaders/material-flow/vertex.glsl deleted file mode 100644 index b075fd9c..00000000 --- a/src/shaders/material-flow/vertex.glsl +++ /dev/null @@ -1,16 +0,0 @@ -in vec3 position; -in vec2 uv; - -uniform sampler2D uFeedbackTexture; - -out vec2 vUv; -out vec2 vFlowSize; -out vec2 dxy; - -void main() { - vUv = uv; - ivec2 flowSize = textureSize(uFeedbackTexture, 0); - vFlowSize = vec2(float(flowSize.x), float(flowSize.y)); - vec2 dxy = vec2(1.0 / vFlowSize.x, 1.0 / vFlowSize.y); - gl_Position = vec4(position, 1.0); -} diff --git a/src/shaders/material-steam/fragment.glsl b/src/shaders/material-steam/fragment.glsl deleted file mode 100644 index 7fb41c41..00000000 --- a/src/shaders/material-steam/fragment.glsl +++ /dev/null @@ -1,60 +0,0 @@ -uniform sampler2D uNoise; -uniform float uTime; - -varying vec2 vUv; - -// Define constants outside main to allow compiler optimizations -const float EDGE_LOWER = 0.0; -const float EDGE_INNER = 0.15; -const float EDGE_OUTER = 0.85; -const float EDGE_UPPER = 1.0; -const float STEAM_THRESHOLD = 0.45; -const vec3 BASE_COLOR = vec3(0.92, 0.78, 0.62); -const float STEAM_SPEED = 0.015; -const float UV_SCALE_X = 0.5; -const float UV_SCALE_Y = 0.3; -const float GRADIENT_SCALE = 1.25; -const float GRADIENT_OFFSET = 0.2; - -void main() { - // Combine UV calculations using multiply-add operations for better GPU utilization - vec2 steamUv = vec2( - vUv.x * UV_SCALE_X, - vUv.y * UV_SCALE_Y - uTime * STEAM_SPEED - ); - - float steam = texture(uNoise, steamUv).r; - steam = smoothstep(STEAM_THRESHOLD, EDGE_UPPER, steam); - - // Edge fade calculations - combined into fewer operations to reduce redundancy - float edgeFadeX = - smoothstep(EDGE_LOWER, EDGE_INNER, vUv.x) * - (1.0 - smoothstep(EDGE_OUTER, EDGE_UPPER, vUv.x)); - float edgeFadeY = - smoothstep(EDGE_LOWER, EDGE_INNER, vUv.y) * - (1.0 - smoothstep(EDGE_OUTER, EDGE_UPPER, vUv.y)); - - steam *= edgeFadeX * edgeFadeY; - - // Simplified checkerboard pattern calculation - reduces arithmetic operations - float pattern = mod( - floor(gl_FragCoord.x * 0.5) + floor(gl_FragCoord.y * 0.5), - 2.0 - ); - - // Optimized gradient calculation using more efficient arithmetic order - float gradient = clamp( - GRADIENT_OFFSET - GRADIENT_SCALE * vUv.y + 1.0, - 0.0, - 1.0 - ); - - float alpha = steam * pattern * gradient; - - // Early fragment discard optimization - skips blending operations for invisible pixels - if (alpha < 0.001) { - discard; - } - - gl_FragColor = vec4(BASE_COLOR, alpha); -} diff --git a/src/shaders/material-steam/vertex.glsl b/src/shaders/material-steam/vertex.glsl deleted file mode 100644 index 7765b616..00000000 --- a/src/shaders/material-steam/vertex.glsl +++ /dev/null @@ -1,27 +0,0 @@ -uniform float uTime; -uniform sampler2D uNoise; - -varying vec2 vUv; - -vec2 rotate2D(vec2 value, float angle) { - float s = sin(angle); - float c = cos(angle); - mat2 m = mat2(c, s, -s, c); - return m * value; -} - -void main() { - vUv = uv; - - vec2 offset = vec2(texture(uNoise, vec2(0.25, uTime * 0.005)).r, 0.0); - offset *= pow(uv.y, 1.2) * 0.035; - - vec3 newPosition = position; - newPosition.xz += offset; - - float twist = texture(uNoise, vec2(0.5, uv.y * 0.2 - uTime * 0.005)).r; - float angle = twist * 8.0; - newPosition.xz = rotate2D(newPosition.xz, angle); - - gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0); -} \ No newline at end of file From 2b7724af32c5cf90159ab089357af525f084f1e6 Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 17:01:09 -0300 Subject: [PATCH 14/21] feat: migrate material-global-shader from GLSL to TSL Replace ShaderMaterial with NodeMaterial + TSL for the global shader material. Convert all #ifdef conditional compilation to TypeScript conditional node graph construction. Convert runtime conditionals to branchless step/mix patterns. Uses TSL basicLight utility for inspection mode lighting. Uniforms compat layer preserves consumer API. Co-Authored-By: Claude Opus 4.5 --- .../material-global-shader/fragment.glsl | 328 ----------- src/shaders/material-global-shader/index.tsx | 518 ++++++++++++++---- .../material-global-shader/vertex.glsl | 26 - 3 files changed, 417 insertions(+), 455 deletions(-) delete mode 100644 src/shaders/material-global-shader/fragment.glsl delete mode 100644 src/shaders/material-global-shader/vertex.glsl diff --git a/src/shaders/material-global-shader/fragment.glsl b/src/shaders/material-global-shader/fragment.glsl deleted file mode 100644 index 4270a13f..00000000 --- a/src/shaders/material-global-shader/fragment.glsl +++ /dev/null @@ -1,328 +0,0 @@ -precision highp float; - -varying vec2 vUv; -varying vec2 vUv2; -varying vec3 vWorldPosition; -varying vec3 vMvPosition; -varying vec3 vNormal; -varying vec3 vViewDirection; - -// Base color -uniform vec3 uColor; -uniform vec3 baseColor; -uniform sampler2D map; -uniform mat3 mapMatrix; -uniform vec2 mapRepeat; - -// Other -uniform float uTime; - -// Lightmap -uniform sampler2D lightMap; -uniform float lightMapIntensity; - -// Lights -#ifdef LIGHT -uniform vec3 lightDirection; -#endif - -#ifdef BASKETBALL -uniform vec3 backLightDirection; -#endif - -// AOMap -uniform sampler2D aoMap; -uniform float aoMapIntensity; - -uniform float noiseFactor; -uniform bool uReverse; - -// Transparency -uniform float opacity; -uniform sampler2D alphaMap; -uniform mat3 alphaMapTransform; - -// Emissive -#ifdef USE_EMISSIVE -uniform vec3 emissive; -uniform float emissiveIntensity; -#endif - -#ifdef USE_EMISSIVEMAP -uniform sampler2D emissiveMap; -uniform float emissiveIntensity; -#endif - -// Fog -#ifdef FOG -uniform vec3 fogColor; -uniform float fogDensity; -uniform float fogDepth; -#endif - -// Matcap -#ifdef MATCAP -uniform sampler2D matcap; -uniform bool glassMatcap; -#endif - -// Glass -#ifdef GLASS -uniform sampler2D glassReflex; -#endif - -// Godray -#ifdef GODRAY -uniform float uGodrayOpacity; -uniform float uGodrayDensity; -#endif - -// Daylight -#ifdef DAYLIGHT -uniform bool daylight; -#endif - -// Inspectable -uniform bool inspectingEnabled; -uniform float inspectingFactor; -uniform float fadeFactor; - -// Lamp -uniform sampler2D lampLightmap; -uniform bool lightLampEnabled; - -#pragma glslify: valueRemap = require('../utils/value-remap.glsl') -#pragma glslify: basicLight = require('../utils/basic-light.glsl') - -void main() { - vec3 normalizedNormal = normalize(vNormal); - vec3 normalizedViewDir = normalize(vViewDirection); - - float oneMinusFadeFactor = 1.0 - fadeFactor; - bool isInspectionMode = inspectingFactor > 0.0; - bool shouldFade = inspectingEnabled && !isInspectionMode; - - #ifdef USE_MAP - vec2 mapUv = (mapMatrix * vec3(vUv, 1.0)).xy * mapRepeat; - #endif - - #ifdef USE_ALPHA_MAP - vec2 alphaMapUv = (alphaMapTransform * vec3(vUv, 1.0)).xy; - #endif - - vec4 mapSample = vec4(1.0); - - #ifdef USE_MAP - mapSample = texture2D(map, mapUv); - #endif - - #ifdef CLOUDS - mapSample = texture2D(map, vec2(vUv.x - uTime * 0.004, vUv.y)); - #endif - - vec3 color = baseColor * mapSample.rgb; - - vec3 lightMapSample = vec3(0.0); - - if (lightLampEnabled) { - lightMapSample = texture2D(lampLightmap, vUv2).rgb; - } else { - lightMapSample = texture2D(lightMap, vUv2).rgb; - } - - vec3 irradiance = color; - - #if defined(USE_EMISSIVE) || defined(USE_EMISSIVEMAP) - float ei = emissiveIntensity; - if (shouldFade) { - ei *= oneMinusFadeFactor; - } - #endif - - #ifdef USE_EMISSIVE - irradiance += emissive * ei; - #endif - - #ifdef USE_EMISSIVEMAP - vec4 emissiveColor = texture2D(emissiveMap, vUv); - irradiance *= emissiveColor.rgb * ei; - #endif - - vec3 lf = irradiance.rgb; - - if (isInspectionMode) { - // Key light - lf *= basicLight(normalizedNormal, normalizedViewDir, 4.0); - // Fill light - vec3 fillLightDir = normalize( - cross(normalizedViewDir, vec3(0.0, 1.0, 0.0)) - ); - lf *= basicLight(normalizedNormal, fillLightDir, 2.0); - // Rim light - vec3 rimLightDir = normalize(-normalizedViewDir + vec3(0.0, 0.5, 0.0)); - lf *= basicLight(normalizedNormal, rimLightDir, 3.0); - - #ifdef MATCAP - vec3 nvz = vec3(-normalizedViewDir.z, 0.0, normalizedViewDir.x); - vec3 x = normalize(nvz); - vec3 y = cross(normalizedViewDir, x); - - vec2 muv = - vec2(dot(x, normalizedNormal), dot(y, normalizedNormal)) * 0.495 + 0.5; - - lf *= texture2D(matcap, muv).rgb; - #endif - } - - #ifndef VIDEO - if (lightMapIntensity > 0.0) { - irradiance *= lightMapSample * lightMapIntensity; - } - #endif - - if (aoMapIntensity > 0.0) { - float ambientOcclusion = - (texture2D(aoMap, vUv2).r - 1.0) * aoMapIntensity + 1.0; - - irradiance *= ambientOcclusion; - } - - if (isInspectionMode) { - if (inspectingFactor < 1.0) { - irradiance = - irradiance * (1.0 - inspectingFactor) + lf * inspectingFactor; - } else { - irradiance = lf; - } - } - - float opacityResult = 1.0; - opacityResult *= opacity; - - #ifdef IS_TRANSPARENT - float mapAlpha = mapSample.a; - opacityResult *= mapAlpha; - #endif - - #ifdef USE_ALPHA_MAP - float alpha = texture2D(alphaMap, alphaMapUv).r; - opacityResult *= alpha; - #endif - - if (opacityResult <= 0.0) { - discard; - } - - #ifdef LIGHT - float dotNL = dot(lightDirection, normalizedNormal); - - float lightFactor = (dotNL - 0.2) * 1.125 + 0.1; - lightFactor = clamp(lightFactor, 0.0, 1.0); - lightFactor = lightFactor * lightFactor; - - #ifdef BASKETBALL - float dotBackNL = dot(backLightDirection, normalizedNormal); - float backLightFactor = (dotBackNL - 0.05) * 0.947368 + 0.1; - backLightFactor = clamp(backLightFactor, 0.0, 1.0); - backLightFactor = backLightFactor * backLightFactor; - - lightFactor *= 8.0; - backLightFactor *= 4.0; - - lightFactor = max(lightFactor, backLightFactor * 1.5); - #else - lightFactor *= 3.0; - #endif - - lightFactor += 1.0; - irradiance *= lightFactor; - #endif - - gl_FragColor = vec4(irradiance, opacityResult); - - #if defined(GLASS) || defined(GODRAY) || defined(MATCAP) && defined(GLASS) - - vec2 shiftedFragCoord = gl_FragCoord.xy + vec2(2.0); - vec2 checkerPos = floor(shiftedFragCoord * 0.5); - float pattern = mod(checkerPos.x + checkerPos.y, 2.0); - #endif - - #ifdef MATCAP - - #if !defined(GLASS) && !defined(GODRAY) - float pattern; - if (glassMatcap) { - vec2 shiftedFragCoord = gl_FragCoord.xy + vec2(2.0); - vec2 checkerPos = floor(shiftedFragCoord * 0.5); - pattern = mod(checkerPos.x + checkerPos.y, 2.0); - } - #endif - #endif - - #ifdef GLASS - - const vec2 glassScale = vec2(0.75); - const vec2 glassOffset = vec2(0.125); - const vec2 viewDirScale = vec2(-0.25, 0.25); - const float mixFactor = 0.075; - - vec2 glassUv = - vUv * glassScale + normalizedViewDir.xy * viewDirScale + glassOffset; - vec4 reflexSample = texture2D(glassReflex, glassUv); - - if (reflexSample.a > 0.0) { - gl_FragColor.rgb = - gl_FragColor.rgb * (1.0 - mixFactor) + reflexSample.rgb * mixFactor; - } - gl_FragColor.a *= pattern; - #endif - - #ifdef GODRAY - gl_FragColor.a *= pattern * uGodrayOpacity * uGodrayDensity; - #endif - - #ifdef FOG - float fogDepthValue = min(vMvPosition.z + fogDepth, 0.0); - float fogDepthSquared = fogDepthValue * fogDepthValue; - float fogDensitySquared = fogDensity * fogDensity; - float fogFactor = 1.0 - exp(-fogDensitySquared * fogDepthSquared); - - fogFactor = clamp(fogFactor, 0.0, 1.0); - - if (fogFactor > 0.0) { - if (fogFactor < 1.0) { - gl_FragColor.rgb = - gl_FragColor.rgb * (1.0 - fogFactor) + fogColor * fogFactor; - } else { - gl_FragColor.rgb = fogColor; - } - } - #endif - - #ifdef IS_LOBO_MARINO - vec3 currentColor = gl_FragColor.rgb; - float fresnelFactor = pow( - 1.0 - dot(normalizedViewDir, normalizedNormal), - 1.0 - ); - vec3 col = mix(uColor, currentColor, 0.5); - col = mix(col, col * 4.0, fresnelFactor); - gl_FragColor.rgb = col; - #endif - - if (shouldFade) { - gl_FragColor.rgb *= oneMinusFadeFactor; - } - - #ifdef MATCAP - if (glassMatcap) { - gl_FragColor.a *= pattern * inspectingFactor; - } - #endif - - #ifdef DAYLIGHT - if (daylight) { - gl_FragColor.a = inspectingFactor; - } - #endif -} diff --git a/src/shaders/material-global-shader/index.tsx b/src/shaders/material-global-shader/index.tsx index ee8ed27d..826e69f5 100644 --- a/src/shaders/material-global-shader/index.tsx +++ b/src/shaders/material-global-shader/index.tsx @@ -1,9 +1,42 @@ -import { Matrix3, MeshStandardMaterial, Vector3 } from "three" -import { Color, ShaderMaterial } from "three" +import { + Color, + Matrix3, + MeshStandardMaterial, + ShaderMaterial, + Texture, + Vector2, + Vector3 +} from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec2, + vec3, + vec4, + uniform, + uv, + texture, + attribute, + positionView, + normalView, + screenCoordinate, + normalize, + cross, + dot, + exp, + mix, + step, + clamp, + min, + max, + floor, + mod, + select +} from "three/tsl" import { create } from "zustand" -import fragmentShader from "./fragment.glsl" -import vertexShader from "./vertex.glsl" +import { basicLight } from "@/shaders/utils/basic-light" export const GLOBAL_SHADER_MATERIAL_NAME = "global-shader-material" @@ -23,132 +56,415 @@ export const createGlobalShaderMaterial = ( } ) => { const { - color: baseColor = new Color(1, 1, 1), + color: baseColorVal = new Color(1, 1, 1), map = null, opacity: baseOpacity = 1.0, - metalness, - roughness, alphaMap, emissiveMap, userData = {} } = baseMaterial - const { lightDirection = null } = userData + const { lightDirection: lightDir = null } = userData const emissiveColor = new Color("#FF4D00").multiplyScalar(9) - const uniforms = { - uColor: { value: emissiveColor }, - uProgress: { value: 0.0 }, - map: { value: map }, - mapMatrix: { value: new Matrix3().identity() }, - lightMap: { value: null }, - lightMapIntensity: { value: 0.0 }, - aoMap: { value: null }, - aoMapIntensity: { value: 0.0 }, - metalness: { value: metalness }, - roughness: { value: roughness }, - mapRepeat: { value: map ? map.repeat : { x: 1, y: 1 } }, - baseColor: { value: baseColor }, - opacity: { value: baseOpacity }, - noiseFactor: { value: 0.5 }, - uTime: { value: 0.0 }, - alphaMap: { value: alphaMap }, - alphaMapTransform: { value: new Matrix3().identity() }, - emissive: { value: baseMaterial.emissive || new Vector3() }, - emissiveIntensity: { value: baseMaterial.emissiveIntensity || 0 }, - fogColor: { value: new Vector3(0.2, 0.2, 0.2) }, - fogDensity: { value: 0.05 }, - fogDepth: { value: 9.0 }, - glassReflex: { value: null }, - emissiveMap: { value: emissiveMap }, - - // Inspectables - inspectingEnabled: { value: false }, - inspectingFactor: { value: 0 }, - fadeFactor: { value: 0 }, - - // Lamp - lampLightmap: { value: null }, - lightLampEnabled: { value: false } - } as Record - - if (defines?.GODRAY) { - uniforms["uGodrayOpacity"] = { value: 0 } - uniforms["uGodrayDensity"] = { value: 0.75 } + // Compile-time feature flags (equivalent to GLSL #ifdef) + const useMap = map !== null + const isTransparent = alphaMap !== null || baseMaterial.transparent + const useAlphaMap = alphaMap !== null + const useEmissive = + baseMaterial.emissiveIntensity !== 0 && emissiveMap === null + const useEmissiveMap = emissiveMap !== null + const isGlass = Boolean(defines?.GLASS) + const isGodray = Boolean(defines?.GODRAY) + const isLight = Boolean(defines?.LIGHT) + const isBasketball = Boolean(defines?.BASKETBALL) + const isFog = defines?.FOG !== undefined ? Boolean(defines.FOG) : true + const isMatcap = Boolean(defines?.MATCAP) + const isClouds = Boolean(defines?.CLOUDS) + const isDaylight = Boolean(defines?.DAYLIGHT) + const isLoboMarino = Boolean(defines?.IS_LOBO_MARINO) + + // --- TSL Uniform Nodes --- + + const uColor = uniform(emissiveColor) + const uBaseColor = uniform(baseColorVal) + const uMap = texture(map || new Texture()) + const uMapMatrix = uniform(new Matrix3().identity()) + const uMapRepeat = uniform( + map ? new Vector2(map.repeat.x, map.repeat.y) : new Vector2(1, 1) + ) + const uLightMap = texture(new Texture()) + const uLightMapIntensity = uniform(0) + const uAoMap = texture(new Texture()) + const uAoMapIntensity = uniform(0) + const uOpacity = uniform(baseOpacity) + const uTime = uniform(0) + const uNoiseFactor = uniform(0.5) + const uAlphaMap = texture(alphaMap || new Texture()) + const uAlphaMapTransform = uniform(new Matrix3().identity()) + const uEmissive = uniform(baseMaterial.emissive || new Vector3()) + const uEmissiveIntensity = uniform(baseMaterial.emissiveIntensity || 0) + const uEmissiveMap = texture(emissiveMap || new Texture()) + const uFogColor = uniform(new Vector3(0.2, 0.2, 0.2)) + const uFogDensity = uniform(0.05) + const uFogDepth = uniform(9.0) + const uGlassReflex = texture(new Texture()) + const uInspectingEnabled = uniform(0) + const uInspectingFactor = uniform(0) + const uFadeFactor = uniform(0) + const uLampLightmap = texture(new Texture()) + const uLightLampEnabled = uniform(0) + + // Conditional uniforms (only created when the feature is active) + let uGodrayOpacity: ReturnType | undefined + let uGodrayDensity: ReturnType | undefined + let uLightDirection: ReturnType | undefined + let uBackLightDirection: ReturnType | undefined + let uMatcapTex: ReturnType | undefined + let uGlassMatcap: ReturnType | undefined + let uDaylight: ReturnType | undefined + + if (isGodray) { + uGodrayOpacity = uniform(0) + uGodrayDensity = uniform(0.75) } - if (defines?.LIGHT) { - uniforms["lightDirection"] = { value: lightDirection } + if (isLight || isBasketball) { + uLightDirection = uniform(lightDir || new Vector3(0, 1, 0)) } - if (defines?.BASKETBALL) { - uniforms["lightDirection"] = { value: lightDirection } - uniforms["backLightDirection"] = { value: new Vector3(0, 0, 1) } + if (isBasketball) { + uBackLightDirection = uniform(new Vector3(0, 0, 1)) } - if (defines?.MATCAP) { - uniforms["matcap"] = { value: null } - uniforms["glassMatcap"] = { value: false } + if (isMatcap) { + uMatcapTex = texture(new Texture()) + uGlassMatcap = uniform(0) } - if (defines?.DAYLIGHT) { - uniforms["daylight"] = { value: true } + if (isDaylight) { + uDaylight = uniform(1) } - const material = new ShaderMaterial({ - name: GLOBAL_SHADER_MATERIAL_NAME, - defines: { - USE_MAP: map !== null, - IS_TRANSPARENT: alphaMap !== null || baseMaterial.transparent, - USE_ALPHA_MAP: alphaMap !== null, - USE_EMISSIVE: - baseMaterial.emissiveIntensity !== 0 && emissiveMap === null, - USE_EMISSIVEMAP: emissiveMap !== null, - GLASS: defines?.GLASS !== undefined ? Boolean(defines?.GLASS) : false, - GODRAY: defines?.GODRAY !== undefined ? Boolean(defines?.GODRAY) : false, - LIGHT: defines?.LIGHT !== undefined ? Boolean(defines?.LIGHT) : false, - BASKETBALL: - defines?.BASKETBALL !== undefined - ? Boolean(defines?.BASKETBALL) - : false, - FOG: defines?.FOG !== undefined ? Boolean(defines?.FOG) : true, - MATCAP: defines?.MATCAP !== undefined ? Boolean(defines?.MATCAP) : false, - VIDEO: defines?.VIDEO !== undefined ? Boolean(defines?.VIDEO) : false, - CLOUDS: defines?.CLOUDS !== undefined ? Boolean(defines?.CLOUDS) : false, - IS_LOBO_MARINO: - defines?.IS_LOBO_MARINO !== undefined - ? Boolean(defines?.IS_LOBO_MARINO) - : false, - DAYLIGHT: - defines?.DAYLIGHT !== undefined ? Boolean(defines?.DAYLIGHT) : false - }, - uniforms, - transparent: - baseOpacity < 1 || - alphaMap !== null || - baseMaterial.transparent || - defines?.DAYLIGHT || - false, - vertexShader, - fragmentShader, - side: baseMaterial.side - }) + // --- Material --- + + const material = new NodeMaterial() + material.name = GLOBAL_SHADER_MATERIAL_NAME + material.transparent = + baseOpacity < 1 || isTransparent || isDaylight + material.side = baseMaterial.side + + // --- Shared vertex/fragment nodes --- + + const vUv1 = uv() + const aUv1 = attribute("uv1", "vec2") + const vUv2 = select(aUv1.x.greaterThan(0.0), aUv1, vUv1) + + // --- Main shader computation --- + + const result = Fn(() => { + const normalizedNormal = normalView + const viewDir = normalize(positionView.negate()) + const oneMinusFadeFactor = float(1.0).sub(uFadeFactor) + const isInspectionMode = step(float(0.001), uInspectingFactor) + const shouldFadeF = uInspectingEnabled.mul( + float(1.0).sub(isInspectionMode) + ) + + // --- Map sampling --- + + let mapSample: any + if (useMap) { + const uvH = vec3(vUv1.x, vUv1.y, float(1.0)) + const mapUv = uMapMatrix.mul(uvH).xy.mul(uMapRepeat) + mapSample = uMap.uv(mapUv) + } else { + mapSample = vec4(1.0, 1.0, 1.0, 1.0) + } + + if (isClouds) { + mapSample = uMap.uv(vec2(vUv1.x.sub(uTime.mul(0.004)), vUv1.y)) + } + + // --- Base color --- + + const color = uBaseColor.mul(mapSample.rgb) + + // --- Lightmap sampling (select between lamp lightmap and regular) --- + + const lightMapSample = mix( + uLightMap.uv(vUv2).rgb, + uLampLightmap.uv(vUv2).rgb, + uLightLampEnabled + ) + + const irradiance = color.toVar() + + // --- Emissive --- + + if (useEmissive || useEmissiveMap) { + const ei = uEmissiveIntensity + .mul(mix(float(1.0), oneMinusFadeFactor, shouldFadeF)) + .toVar() + + if (useEmissive) { + irradiance.addAssign(uEmissive.mul(ei)) + } + + if (useEmissiveMap) { + irradiance.mulAssign(uEmissiveMap.uv(vUv1).rgb.mul(ei)) + } + } + + // --- Inspection mode lighting --- + // Always computed; mixed into irradiance based on inspectingFactor + + const lf = irradiance.toVar() + + lf.mulAssign(basicLight(normalizedNormal, viewDir, float(4.0))) + + const fillLightDir = normalize(cross(viewDir, vec3(0.0, 1.0, 0.0))) + lf.mulAssign(basicLight(normalizedNormal, fillLightDir, float(2.0))) + + const rimLightDir = normalize( + viewDir.negate().add(vec3(0.0, 0.5, 0.0)) + ) + lf.mulAssign(basicLight(normalizedNormal, rimLightDir, float(3.0))) + + if (isMatcap) { + const nvz = vec3(viewDir.z.negate(), float(0.0), viewDir.x) + const xAxis = normalize(nvz) + const yAxis = cross(viewDir, xAxis) + const matcapUv = vec2( + dot(xAxis, normalizedNormal), + dot(yAxis, normalizedNormal) + ) + .mul(0.495) + .add(0.5) + lf.mulAssign(uMatcapTex!.uv(matcapUv).rgb) + } + + // --- Lightmap application (skip for VIDEO) --- + + if (!defines?.VIDEO) { + const lmFactor = mix( + vec3(1.0, 1.0, 1.0), + lightMapSample.mul(uLightMapIntensity), + step(float(0.001), uLightMapIntensity) + ) + irradiance.mulAssign(lmFactor) + } + + // --- AO map --- + + const ao = uAoMap.uv(vUv2).r.sub(1.0).mul(uAoMapIntensity).add(1.0) + irradiance.mulAssign( + mix(float(1.0), ao, step(float(0.001), uAoMapIntensity)) + ) + + // --- Blend inspection lighting --- + + irradiance.assign( + mix(irradiance, lf, clamp(uInspectingFactor, 0.0, 1.0)) + ) + + // --- Opacity --- + + const opacityResult = uOpacity.toVar() + + if (isTransparent) { + opacityResult.mulAssign(mapSample.a) + } + + if (useAlphaMap) { + const alphaUv = uAlphaMapTransform + .mul(vec3(vUv1.x, vUv1.y, float(1.0))) + .xy + opacityResult.mulAssign(uAlphaMap.uv(alphaUv).r) + } + + // Discard fully transparent + opacityResult.lessThan(float(0.001)).discard() + + // --- Directional lighting --- + + if (isLight) { + const dotNL = dot(uLightDirection!, normalizedNormal) + const lightFactor = dotNL.sub(0.2).mul(1.125).add(0.1).toVar() + lightFactor.assign(clamp(lightFactor, 0.0, 1.0)) + lightFactor.assign(lightFactor.mul(lightFactor)) + + if (isBasketball) { + const dotBackNL = dot(uBackLightDirection!, normalizedNormal) + const backLF = dotBackNL.sub(0.05).mul(0.947368).add(0.1).toVar() + backLF.assign(clamp(backLF, 0.0, 1.0)) + backLF.assign(backLF.mul(backLF)) + lightFactor.mulAssign(8.0) + backLF.mulAssign(4.0) + lightFactor.assign(max(lightFactor, backLF.mul(1.5))) + } else { + lightFactor.mulAssign(3.0) + } + + lightFactor.addAssign(1.0) + irradiance.mulAssign(lightFactor) + } + + // --- Checker pattern (GLASS / GODRAY / MATCAP) --- + + let pattern: any + if (isGlass || isGodray || isMatcap) { + const shiftedCoord = screenCoordinate.xy.add(vec2(2.0, 2.0)) + const checkerPos = floor(shiftedCoord.mul(0.5)) + pattern = mod(checkerPos.x.add(checkerPos.y), 2.0) + } + + // --- Glass reflex --- + + if (isGlass) { + const glassUv = vUv1 + .mul(vec2(0.75, 0.75)) + .add(viewDir.xy.mul(vec2(-0.25, 0.25))) + .add(vec2(0.125, 0.125)) + const reflexSample = uGlassReflex.uv(glassUv) + const reflexActive = step(float(0.001), reflexSample.a) + const mixF = float(0.075) + const reflexBlend = irradiance + .mul(float(1.0).sub(mixF)) + .add(reflexSample.rgb.mul(mixF)) + irradiance.assign(mix(irradiance, reflexBlend, reflexActive)) + opacityResult.mulAssign(pattern!) + } + + // --- Godray --- + + if (isGodray) { + opacityResult.mulAssign( + pattern!.mul(uGodrayOpacity!).mul(uGodrayDensity!) + ) + } + + // --- Fog --- + + if (isFog) { + const fogDepthVal = min(positionView.z.add(uFogDepth), float(0.0)) + const fogDensSq = uFogDensity.mul(uFogDensity) + const fogDepthSq = fogDepthVal.mul(fogDepthVal) + const fogFactor = clamp( + float(1.0).sub(exp(fogDensSq.mul(fogDepthSq).negate())), + 0.0, + 1.0 + ) + irradiance.assign(mix(irradiance, uFogColor, fogFactor)) + } + + // --- Lobo Marino fresnel --- + + if (isLoboMarino) { + const fresnelFactor = float(1.0).sub(dot(viewDir, normalizedNormal)) + const col = mix(uColor, irradiance, 0.5).toVar() + col.assign(mix(col, col.mul(4.0), fresnelFactor)) + irradiance.assign(col) + } + + // --- Fade --- + + irradiance.mulAssign(mix(float(1.0), oneMinusFadeFactor, shouldFadeF)) + + // --- MATCAP glassMatcap alpha --- + + if (isMatcap) { + opacityResult.mulAssign( + mix(float(1.0), pattern!.mul(uInspectingFactor), uGlassMatcap!) + ) + } + + // --- DAYLIGHT alpha --- + + if (isDaylight) { + opacityResult.assign( + mix(opacityResult, uInspectingFactor, uDaylight!) + ) + } + + return vec4(irradiance, opacityResult) + })() + + material.colorNode = result.rgb + material.opacityNode = result.a + + // --- Uniforms compatibility layer --- + // Consumers access material.uniforms.X.value; TSL nodes have .value natively + + const uniformsCompat: Record = { + uColor, + uProgress: uniform(0), + map: uMap, + mapMatrix: uMapMatrix, + lightMap: uLightMap, + lightMapIntensity: uLightMapIntensity, + aoMap: uAoMap, + aoMapIntensity: uAoMapIntensity, + metalness: uniform(baseMaterial.metalness), + roughness: uniform(baseMaterial.roughness), + mapRepeat: uMapRepeat, + baseColor: uBaseColor, + opacity: uOpacity, + noiseFactor: uNoiseFactor, + uTime, + alphaMap: uAlphaMap, + alphaMapTransform: uAlphaMapTransform, + emissive: uEmissive, + emissiveIntensity: uEmissiveIntensity, + fogColor: uFogColor, + fogDensity: uFogDensity, + fogDepth: uFogDepth, + glassReflex: uGlassReflex, + emissiveMap: uEmissiveMap, + inspectingEnabled: uInspectingEnabled, + inspectingFactor: uInspectingFactor, + fadeFactor: uFadeFactor, + lampLightmap: uLampLightmap, + lightLampEnabled: uLightLampEnabled + } + + if (isGodray) { + uniformsCompat.uGodrayOpacity = uGodrayOpacity! + uniformsCompat.uGodrayDensity = uGodrayDensity! + } + + if (isLight || isBasketball) { + uniformsCompat.lightDirection = uLightDirection! + } + + if (isBasketball) { + uniformsCompat.backLightDirection = uBackLightDirection! + } + + if (isMatcap) { + uniformsCompat.matcap = uMatcapTex! + uniformsCompat.glassMatcap = uGlassMatcap! + } + + if (isDaylight) { + uniformsCompat.daylight = uDaylight! + } + + ;(material as any).uniforms = uniformsCompat material.needsUpdate = true - material.customProgramCacheKey = () => GLOBAL_SHADER_MATERIAL_NAME - useCustomShaderMaterial.getState().addMaterial(material) + useCustomShaderMaterial + .getState() + .addMaterial(material as unknown as ShaderMaterial) baseMaterial.dispose() - return material + return material as unknown as ShaderMaterial } interface CustomShaderMaterialStore { - /** - * Will not cause re-renders to use this object - */ materialsRef: Record addMaterial: (material: ShaderMaterial) => void removeMaterial: (id: number) => void diff --git a/src/shaders/material-global-shader/vertex.glsl b/src/shaders/material-global-shader/vertex.glsl deleted file mode 100644 index d006e66d..00000000 --- a/src/shaders/material-global-shader/vertex.glsl +++ /dev/null @@ -1,26 +0,0 @@ -attribute vec2 uv1; - -varying vec2 vUv; -varying vec3 vWorldPosition; -varying vec3 vMvPosition; -varying vec3 vNormal; -varying vec3 vViewDirection; -varying vec2 vUv2; - -void main() { - vUv = uv; - vUv2 = uv1.x > 0.0 ? uv1 : uv; - - vNormal = normalize(normalMatrix * normal); - - vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); - vec4 worldPosition = modelMatrix * vec4(position, 1.0); - - // Calculate view direction in view space - vViewDirection = normalize(-mvPosition.xyz); - - vMvPosition = mvPosition.xyz; - vWorldPosition = worldPosition.xyz; - - gl_Position = projectionMatrix * mvPosition; -} From 5cead2b08252af5579da92dffd086f2819d2fc2b Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 17:07:03 -0300 Subject: [PATCH 15/21] feat: migrate CRT mesh inline shaders from GLSL to TSL Replace inline GLSL vertex/fragment strings with NodeMaterial + TSL node graph. Convert barrel distortion, chromatic aberration, scanlines, flicker, vignette, noise, and phosphor glow to TSL Fn helpers. Convert bounds check to branchless step multiplication. Remove dead uResolution uniform. Co-Authored-By: Claude Opus 4.5 --- src/components/doom-js/crt-mesh.tsx | 295 +++++++++++++++------------- 1 file changed, 154 insertions(+), 141 deletions(-) diff --git a/src/components/doom-js/crt-mesh.tsx b/src/components/doom-js/crt-mesh.tsx index bf19f0cb..40d12bf9 100644 --- a/src/components/doom-js/crt-mesh.tsx +++ b/src/components/doom-js/crt-mesh.tsx @@ -1,125 +1,158 @@ "use client" -import { useMemo, useRef } from "react" -import { CanvasTexture, ShaderMaterial, Vector2 } from "three" +import { useEffect, useMemo, useRef } from "react" +import { CanvasTexture, Texture } from "three" import * as THREE from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec2, + vec3, + uniform, + uv, + texture as tslTexture, + sin, + dot, + fract, + clamp, + pow, + mix, + step +} from "three/tsl" import { useFrameCallback } from "@/hooks/use-pausable-time" -// CRT Shader -const vertexShader = /*glsl*/ ` - varying vec2 vUv; - - void main() { - vUv = uv; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - } -` - -const fragmentShader = /*glsl*/ ` - uniform sampler2D uTexture; - uniform float uTime; - uniform vec2 uResolution; - uniform float uCurvature; - uniform float uScanlineIntensity; - uniform float uScanlineCount; - uniform float uVignette; - uniform float uBrightness; - uniform float uContrast; - - varying vec2 vUv; - - // CRT barrel distortion - vec2 barrelDistortion(vec2 coord, float amt) { - vec2 cc = coord - 0.5; - float dist = dot(cc, cc); - return coord + cc * dist * amt; - } - - // Chromatic aberration - vec3 chromaticAberration(sampler2D tex, vec2 uv, float amount) { - float aberrationAmount = amount * 0.01; - vec2 distFromCenter = uv - 0.5; - - vec2 aberratedUv = barrelDistortion(uv, uCurvature); - - float r = texture2D(tex, aberratedUv - distFromCenter * aberrationAmount).r; - float g = texture2D(tex, aberratedUv).g; - float b = texture2D(tex, aberratedUv + distFromCenter * aberrationAmount).b; - - return vec3(r, g, b); - } - - // Scanlines - float scanline(float uv, float resolution, float opacity) { - float intensity = sin(uv * resolution * 3.14159265359 * 2.0); - intensity = ((0.5 * intensity) + 0.5) * 0.9 + 0.1; - return clamp(intensity, 0.0, 1.0) * opacity + (1.0 - opacity); - } - - // Vignette - float vignette(vec2 uv, float intensity) { - vec2 vignetteUv = uv * (1.0 - uv.yx); - float vig = vignetteUv.x * vignetteUv.y * 15.0; - return pow(vig, intensity); - } - - // Noise - float random(vec2 co) { - return fract(sin(dot(co.xy, vec2(12.9898, 78.233))) * 43758.5453); - } - - void main() { - vec2 uv = vUv; - +const createCRTMaterial = () => { + const uTexture = tslTexture(new Texture()) + const uTime = uniform(0) + const uCurvature = uniform(0.3) + const uScanlineIntensity = uniform(0.75) + const uScanlineCount = uniform(200) + const uVignetteIntensity = uniform(0.3) + const uBrightness = uniform(0.05) + const uContrast = uniform(1.2) + + const material = new NodeMaterial() + + // Barrel distortion: warp UV based on distance from center + const barrelDistortionFn = /* @__PURE__ */ Fn(([coord, amt]: [any, any]) => { + const cc = coord.sub(0.5) + const dist = dot(cc, cc) + return coord.add(cc.mul(dist).mul(amt)) + }) + + // Scanline: sin-based scanline pattern + const scanlineFn = /* @__PURE__ */ Fn( + ([uvY, resolution, opacity]: [any, any, any]) => { + const intensity = sin(uvY.mul(resolution).mul(Math.PI * 2.0)) + .mul(0.5) + .add(0.5) + .mul(0.9) + .add(0.1) + return clamp(intensity, 0.0, 1.0) + .mul(opacity) + .add(float(1.0).sub(opacity)) + } + ) + + // Vignette: darken edges + const vignetteFn = /* @__PURE__ */ Fn( + ([uvCoord, vigIntensity]: [any, any]) => { + const vigUv = vec2( + uvCoord.x.mul(float(1.0).sub(uvCoord.y)), + uvCoord.y.mul(float(1.0).sub(uvCoord.x)) + ) + const vig = vigUv.x.mul(vigUv.y).mul(15.0) + return pow(vig, vigIntensity) + } + ) + + // Hash-based noise + const randomFn = /* @__PURE__ */ Fn(([co]: [any]) => { + return fract(sin(dot(co, vec2(12.9898, 78.233))).mul(43758.5453)) + }) + + material.colorNode = Fn(() => { + const vUv = uv() + // Apply barrel distortion - vec2 distortedUv = barrelDistortion(uv, uCurvature); - - // Check if we're outside the screen bounds after distortion - if (distortedUv.x < 0.0 || distortedUv.x > 1.0 || distortedUv.y < 0.0 || distortedUv.y > 1.0) { - gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); - return; + const distortedUv = barrelDistortionFn(vUv, uCurvature) + + // Branchless bounds check: 1 when inside [0,1], 0 when outside + const inBounds = step(float(0.0), distortedUv.x) + .mul(step(distortedUv.x, float(1.0))) + .mul(step(float(0.0), distortedUv.y)) + .mul(step(distortedUv.y, float(1.0))) + + // Chromatic aberration + const aberrationAmount = float(0.01) + const distFromCenter = vUv.sub(0.5) + + const r = uTexture + .uv(distortedUv.sub(distFromCenter.mul(aberrationAmount))) + .r + const g = uTexture.uv(distortedUv).g + const b = uTexture + .uv(distortedUv.add(distFromCenter.mul(aberrationAmount))) + .b + + const color = vec3(r, g, b).toVar() + + // Scanlines + color.mulAssign( + scanlineFn(distortedUv.y, uScanlineCount, uScanlineIntensity) + ) + + // Flicker + color.mulAssign(float(1.0).add(sin(uTime.mul(6.0)).mul(0.01))) + + // Brightness and contrast + color.assign(color.sub(0.5).mul(uContrast).add(0.5).add(uBrightness)) + + // Vignette + color.mulAssign(vignetteFn(distortedUv, uVignetteIntensity)) + + // Subtle noise + color.addAssign(randomFn(distortedUv.add(uTime)).mul(0.03)) + + // Phosphor glow (green phosphor boost: g *= 1.05 * 1.1) + const phosphor = vec3( + color.x.mul(1.05), + color.y.mul(1.155), + color.z.mul(1.05) + ) + color.assign(mix(color, phosphor, 0.3)) + + // Clamp + color.assign(clamp(color, 0.0, 1.0)) + + // Gamma correction + color.assign(pow(color, vec3(1.5, 1.5, 1.5))) + + // Final: multiply by 4 and mask out-of-bounds regions + color.mulAssign(4.0) + color.mulAssign(inBounds) + + return color + })() + + material.opacityNode = float(1.0) + + return { + material, + uniforms: { + uTexture, + uTime, + uCurvature, + uScanlineIntensity, + uScanlineCount, + uVignette: uVignetteIntensity, + uBrightness, + uContrast } - - // Get color with chromatic aberration - vec3 color = chromaticAberration(uTexture, uv, 1.0); - - // Apply scanlines - float scanlineValue = scanline(distortedUv.y, uScanlineCount, uScanlineIntensity); - color *= scanlineValue; - - // Add some flicker - float flicker = 1.0 + sin(uTime * 6.0) * 0.01; - color *= flicker; - - // Apply brightness and contrast - color = (color - 0.5) * uContrast + 0.5 + uBrightness; - - // Apply vignette - float vignetteValue = vignette(distortedUv, uVignette); - color *= vignetteValue; - - // Add subtle noise - float noise = random(distortedUv + uTime) * 0.03; - color += noise; - - // Add phosphor glow effect - vec3 phosphor = color * 1.05; - phosphor.g *= 1.1; // Green phosphor was common in old CRTs - color = mix(color, phosphor, 0.3); - - // Clamp values - color = clamp(color, 0.0, 1.0); - - color = vec3( - pow(color.x, 1.5), - pow(color.y, 1.5), - pow(color.z, 1.5) - ); - - gl_FragColor = vec4(color * 4., 1.0); } -` +} interface CRTMeshProps { texture: CanvasTexture @@ -127,41 +160,21 @@ interface CRTMeshProps { export function CRTMesh({ texture }: CRTMeshProps) { const meshRef = useRef(null) - const materialRef = useRef(null) - - // Create shader material with uniforms - const uniforms = useMemo( - () => ({ - uTexture: { value: texture }, - uTime: { value: 0 }, - uResolution: { value: new Vector2(320, 200) }, - uCurvature: { value: 0.3 }, - uScanlineIntensity: { value: 0.75 }, - uScanlineCount: { value: 200 }, - uVignette: { value: 0.3 }, - uBrightness: { value: 0.05 }, - uContrast: { value: 1.2 } - }), - [texture] - ) - // Update time uniform for animated effects + const { material, uniforms } = useMemo(() => createCRTMaterial(), []) + + useEffect(() => { + uniforms.uTexture.value = texture + }, [texture, uniforms]) + useFrameCallback((state) => { - if (materialRef.current) { - materialRef.current.uniforms.uTime.value = state.clock.getElapsedTime() - } + uniforms.uTime.value = state.clock.getElapsedTime() }) return ( - + ) } From 251845db1f60932eb09eafc4e686158382e0939a Mon Sep 17 00:00:00 2001 From: ragojose Date: Wed, 4 Feb 2026 17:10:16 -0300 Subject: [PATCH 16/21] feat: migrate loading scene inline shaders from GLSL to TSL Replace inline GLSL vertex/fragment strings for lines material with NodeMaterial + TSL. The shader outputs flat gray color based on uOpacity (the original reveal computation was dead code). Remove WebGLRenderer type dependency from renderFlow function. Co-Authored-By: Claude Opus 4.5 --- .../loading/loading-scene/index.tsx | 72 +++++-------------- 1 file changed, 16 insertions(+), 56 deletions(-) diff --git a/src/components/loading/loading-scene/index.tsx b/src/components/loading/loading-scene/index.tsx index 43d978b3..5d42f8d5 100644 --- a/src/components/loading/loading-scene/index.tsx +++ b/src/components/loading/loading-scene/index.tsx @@ -13,9 +13,10 @@ import { Raycaster, ShaderMaterial, Vector2, - Vector3, - WebGLRenderer + Vector3 } from "three" +import { NodeMaterial } from "three/webgpu" +import { float, uniform, vec3 } from "three/tsl" import { GLTF } from "three/examples/jsm/Addons.js" import { create } from "zustand" @@ -157,59 +158,18 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { l.renderOrder = 2 - l.material = new ShaderMaterial({ - // depthTest: false, - transparent: true, - // depthWrite: false, - uniforms: { - uReveal: { value: 0 }, - uColor: { value: new Color("#FF4D00") }, - uOpacity: { value: 0 } - }, - vertexShader: /*glsl*/ ` - varying vec3 vWorldPosition; - void main() { - vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; - - // Transform to view space - vec4 viewPos = viewMatrix * vec4(vWorldPosition, 1.0); - - // Move slightly toward the camera in view space - // The negative Z direction is toward the camera in view space - // viewPos.z -= 0.01; // Small offset to prevent z-fighting - - // Complete the projection - gl_Position = projectionMatrix * viewPos; - } - `, - fragmentShader: /*glsl*/ ` - uniform float uReveal; - uniform vec3 uColor; - uniform float uOpacity; - varying vec3 vWorldPosition; - - - float minPosition = -4.0; - float maxPosition = -30.0; - - float valueRemap(float value, float min, float max, float newMin, float newMax) { - return newMin + (value - min) * (newMax - newMin) / (max - min); - } - - float valueRemapClamp(float value, float min, float max, float newMin, float newMax) { - return clamp(valueRemap(value, min, max, newMin, newMax), newMin, newMax); - } - - void main() { - - float edgePos = valueRemap(uReveal, 0.0, 1.0, minPosition, maxPosition); - - float reveal = vWorldPosition.z < edgePos ? 0.0 : 1.0; - - gl_FragColor = vec4(vec3(uOpacity), 1.0); - } - ` - }) + const uReveal = uniform(0) + const uColor = uniform(new Color("#FF4D00")) + const uOpacity = uniform(0) + + const lineMaterial = new NodeMaterial() + lineMaterial.transparent = true + lineMaterial.colorNode = vec3(uOpacity, uOpacity, uOpacity) + lineMaterial.opacityNode = float(1.0) + + ;(lineMaterial as any).uniforms = { uReveal, uColor, uOpacity } + + l.material = lineMaterial return l }, [nodes]) @@ -308,7 +268,7 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { const camera = useThree((s) => s.camera) const pointer = useThree((s) => s.pointer) - const renderFlow = (gl: WebGLRenderer, delta: number) => { + const renderFlow = (gl: any, delta: number) => { gl.setRenderTarget(null) vRefsFloor.smoothPointer.lerp(pointer, Math.min(delta * 10, 1)) From 8e81795bc783c8a8b89e7ae86e4ef08d6e4ad472 Mon Sep 17 00:00:00 2001 From: ragojose Date: Thu, 5 Feb 2026 11:04:14 -0300 Subject: [PATCH 17/21] feat: complete WebGL to WebGPU migration - characters, lamp, and remaining fixes - Migrate material-characters from GLSL to TSL with custom positionNode handling indirect lookup, morphs, skinning, and batch matrix - Override setupPosition to prevent BatchNode interference with character positions - Migrate lamp component to use Line2NodeMaterial and MeshDiscardMaterial - Fix boolean-to-float uniform assignments for WebGPU pipeline compatibility (lightLampEnabled, inspectingEnabled, glassMatcap) - Add MeshDiscardMaterial component using NodeMaterial - Add three-tsl.d.ts type declarations for TSL texture sampling - Remove GLSL shader files and declaration types - Remove meshline dependency in favor of native Line2 - Fix next/image quality configuration warnings - Update remaining components to use WebGPU-compatible imports Co-Authored-By: Claude Opus 4.5 --- next.config.ts | 18 +- package.json | 5 - pnpm-lock.yaml | 608 +----------------- src/components/arcade-board/button.tsx | 2 +- src/components/arcade-board/stick.tsx | 2 +- src/components/blog-door/index.tsx | 2 +- .../characters/character-instancer.tsx | 53 +- .../instanced-skinned-mesh/index.tsx | 2 + .../instanced-skinned-mesh.ts | 195 +----- src/components/christmas-tree/client.tsx | 4 +- src/components/clock/index.tsx | 2 +- src/components/doom-js/crt-mesh.tsx | 14 +- src/components/inspectables/inspectable.tsx | 2 +- src/components/lamp/index.tsx | 45 +- src/components/locked-door/index.tsx | 2 +- src/components/map/bakes.tsx | 2 +- src/components/map/index.tsx | 3 +- src/components/map/use-frame-loop.ts | 2 +- src/components/mesh-discard-material.tsx | 11 + src/components/pets/index.tsx | 8 +- .../routing-element/routing-arrow.tsx | 84 +-- .../routing-element/routing-plus.tsx | 110 ++-- src/components/sparkles/index.tsx | 2 +- src/components/speaker-hover/index.tsx | 2 +- src/components/weather/index.tsx | 3 - src/declarations/glsl.d.ts | 4 - src/declarations/shader.d.ts | 9 - src/declarations/three-tsl.d.ts | 8 + src/shaders/material-characters/fragment.glsl | 97 --- src/shaders/material-characters/index.ts | 402 +++++++++++- src/shaders/material-characters/vertex.glsl | 101 --- src/shaders/material-flow/index.ts | 28 +- src/shaders/material-global-shader/index.tsx | 22 +- src/shaders/material-net/index.ts | 4 +- src/shaders/material-not-found/index.ts | 8 +- src/shaders/material-postprocessing/index.ts | 28 +- src/shaders/material-screen/index.ts | 24 +- src/shaders/material-solid-reveal/index.ts | 4 +- src/shaders/material-steam/index.ts | 6 +- src/shaders/utils/basic-light.glsl | 14 - src/shaders/utils/value-remap.glsl | 30 - src/workers/loading-worker.tsx | 28 + 42 files changed, 688 insertions(+), 1312 deletions(-) create mode 100644 src/components/mesh-discard-material.tsx delete mode 100644 src/declarations/glsl.d.ts delete mode 100644 src/declarations/shader.d.ts create mode 100644 src/declarations/three-tsl.d.ts delete mode 100644 src/shaders/material-characters/fragment.glsl delete mode 100644 src/shaders/material-characters/vertex.glsl delete mode 100644 src/shaders/utils/basic-light.glsl delete mode 100644 src/shaders/utils/value-remap.glsl diff --git a/next.config.ts b/next.config.ts index 275ff4e7..f4619da5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,19 +3,12 @@ import type { NextConfig } from "next" const nextConfig: NextConfig = { reactStrictMode: false, productionBrowserSourceMaps: true, - turbopack: { - rules: { - "*.{glsl,vert,frag,vs,fs}": { - loaders: ["raw-loader", "glslify-loader"], - as: "*.js" - } - } - }, experimental: { ppr: "incremental" }, images: { + qualities: [75, 100], formats: ["image/avif", "image/webp"], remotePatterns: [ { @@ -38,15 +31,6 @@ const nextConfig: NextConfig = { ] }, - webpack: (config) => { - config.module.rules.push({ - test: /\.(glsl|vs|fs|vert|frag)$/, - use: ["raw-loader", "glslify-loader"] - }) - - return config - }, - async rewrites() { return [ { diff --git a/package.json b/package.json index cfd9957a..3ff607fd 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,6 @@ "basehub": "8.0.0-canary.27", "clsx": "^2.1.1", "emulators": "^8.3.9", - "glsl-noise": "^0.0.0", - "glslify-loader": "^2.0.0", "jquery": "^3.7.1", "js-dos": "^8.3.20", "leva": "^0.9.35", @@ -50,7 +48,6 @@ "posthog-js": "^1.234.10", "posthog-node": "^4.11.2", "r3f-perf": "^7.2.3", - "raw-loader": "^4.0.2", "react": "19.0.1", "react-device-detect": "^2.2.3", "react-dom": "19.0.1", @@ -80,13 +77,11 @@ "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-simple-import-sort": "^12.1.1", "glsl-constants": "^2.0.1", - "glslify-loader": "^2.0.0", "postcss": "^8", "postcss-nesting": "^13.0.1", "prettier": "^3.3.3", "prettier-plugin-glsl": "^0.2.0", "prettier-plugin-tailwindcss": "^0.6.9", - "raw-loader": "^4.0.2", "tailwindcss": "^3.4.1", "typescript": "^5.8.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 139a74c6..8f21122f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,12 +80,6 @@ importers: emulators: specifier: ^8.3.9 version: 8.3.9 - glsl-noise: - specifier: ^0.0.0 - version: 0.0.0 - glslify-loader: - specifier: ^2.0.0 - version: 2.0.0 jquery: specifier: ^3.7.1 version: 3.7.1 @@ -125,9 +119,6 @@ importers: r3f-perf: specifier: ^7.2.3 version: 7.2.3(@react-three/fiber@9.0.0-rc.6(@types/react@19.0.0)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(three@0.180.0))(@types/react@19.0.0)(@types/three@0.170.0)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(three@0.180.0)(use-sync-external-store@1.4.0(react@19.0.1)) - raw-loader: - specifier: ^4.0.2 - version: 4.0.2(webpack@5.97.1) react: specifier: 19.0.1 version: 19.0.1 @@ -339,10 +330,6 @@ packages: '@chevrotain/utils@10.5.0': resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} - '@choojs/findup@0.2.1': - resolution: {integrity: sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==} - hasBin: true - '@clack/core@0.3.5': resolution: {integrity: sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==} @@ -960,9 +947,6 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.6': - resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} - '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} @@ -1948,9 +1932,6 @@ packages: '@types/draco3d@1.4.10': resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} - '@types/eslint-scope@3.7.7': - resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} - '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} @@ -2151,63 +2132,12 @@ packages: vue-router: optional: true - '@webassemblyjs/ast@1.14.1': - resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} - - '@webassemblyjs/floating-point-hex-parser@1.13.2': - resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} - - '@webassemblyjs/helper-api-error@1.13.2': - resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} - - '@webassemblyjs/helper-buffer@1.14.1': - resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} - - '@webassemblyjs/helper-numbers@1.13.2': - resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': - resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} - - '@webassemblyjs/helper-wasm-section@1.14.1': - resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} - - '@webassemblyjs/ieee754@1.13.2': - resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} - - '@webassemblyjs/leb128@1.13.2': - resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} - - '@webassemblyjs/utf8@1.13.2': - resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} - - '@webassemblyjs/wasm-edit@1.14.1': - resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} - - '@webassemblyjs/wasm-gen@1.14.1': - resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} - - '@webassemblyjs/wasm-opt@1.14.1': - resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} - - '@webassemblyjs/wasm-parser@1.14.1': - resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} - - '@webassemblyjs/wast-printer@1.14.1': - resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - '@webgpu/types@0.1.51': resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==} '@xstate/fsm@1.6.5': resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} - '@xtuc/ieee754@1.2.0': - resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} - - '@xtuc/long@4.2.2': - resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2222,11 +2152,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} - peerDependencies: - ajv: ^6.9.1 - ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -2380,9 +2305,6 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - big.js@5.2.2: - resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} - binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2406,9 +2328,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2478,10 +2397,6 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - chrome-trace-event@1.0.4: - resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} - engines: {node: '>=6.0'} - ci-info@2.0.0: resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} @@ -2763,10 +2678,6 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - emojis-list@3.0.0: - resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} - engines: {node: '>= 4'} - emulators@8.3.9: resolution: {integrity: sha512-KRoi5rvWCrRTzboCQlftbASdsdmnAtkGQdBTcjXV9GZ9hmGL01cxDVUQYpKSH0O4Lcoatwb+2HcYUJFohijNmw==} @@ -2801,9 +2712,6 @@ packages: resolution: {integrity: sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q==} engines: {node: '>= 0.4'} - es-module-lexer@1.5.4: - resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2960,10 +2868,6 @@ packages: peerDependencies: eslint: '>=5.0.0' - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - eslint-scope@8.3.0: resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3007,10 +2911,6 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -3034,10 +2934,6 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} @@ -3243,9 +3139,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -3278,55 +3171,9 @@ packages: resolution: {integrity: sha512-+92w2eioJ6df1R+nd4BAbaCOZxYCJ6n33JgglwId1TlQ/w4kjPdnnNLxrLY4UjTk0C2XX43x7cFL2h62HtefwA==} engines: {node: '>=16.0.0', npm: '>=7.0.0'} - glsl-inject-defines@1.0.3: - resolution: {integrity: sha512-W49jIhuDtF6w+7wCMcClk27a2hq8znvHtlGnrYkSWEr8tHe9eA2dcnohlcAmxLYBSpSSdzOkRdyPTrx9fw49+A==} - glsl-noise@0.0.0: resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==} - glsl-resolve@0.0.1: - resolution: {integrity: sha512-xxFNsfnhZTK9NBhzJjSBGX6IOqYpvBHxxmo+4vapiljyGNCY0Bekzn0firQkQrazK59c1hYxMDxYS8MDlhw4gA==} - - glsl-token-assignments@2.0.2: - resolution: {integrity: sha512-OwXrxixCyHzzA0U2g4btSNAyB2Dx8XrztY5aVUCjRSh4/D0WoJn8Qdps7Xub3sz6zE73W3szLrmWtQ7QMpeHEQ==} - - glsl-token-defines@1.0.0: - resolution: {integrity: sha512-Vb5QMVeLjmOwvvOJuPNg3vnRlffscq2/qvIuTpMzuO/7s5kT+63iL6Dfo2FYLWbzuiycWpbC0/KV0biqFwHxaQ==} - - glsl-token-depth@1.1.2: - resolution: {integrity: sha512-eQnIBLc7vFf8axF9aoi/xW37LSWd2hCQr/3sZui8aBJnksq9C7zMeUYHVJWMhFzXrBU7fgIqni4EhXVW4/krpg==} - - glsl-token-descope@1.0.2: - resolution: {integrity: sha512-kS2PTWkvi/YOeicVjXGgX5j7+8N7e56srNDEHDTVZ1dcESmbmpmgrnpjPcjxJjMxh56mSXYoFdZqb90gXkGjQw==} - - glsl-token-inject-block@1.1.0: - resolution: {integrity: sha512-q/m+ukdUBuHCOtLhSr0uFb/qYQr4/oKrPSdIK2C4TD+qLaJvqM9wfXIF/OOBjuSA3pUoYHurVRNao6LTVVUPWA==} - - glsl-token-properties@1.0.1: - resolution: {integrity: sha512-dSeW1cOIzbuUoYH0y+nxzwK9S9O3wsjttkq5ij9ZGw0OS41BirKJzzH48VLm8qLg+au6b0sINxGC0IrGwtQUcA==} - - glsl-token-scope@1.1.2: - resolution: {integrity: sha512-YKyOMk1B/tz9BwYUdfDoHvMIYTGtVv2vbDSLh94PT4+f87z21FVdou1KNKgF+nECBTo0fJ20dpm0B1vZB1Q03A==} - - glsl-token-string@1.0.1: - resolution: {integrity: sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==} - - glsl-token-whitespace-trim@1.0.0: - resolution: {integrity: sha512-ZJtsPut/aDaUdLUNtmBYhaCmhIjpKNg7IgZSfX5wFReMc2vnj8zok+gB/3Quqs0TsBSX/fGnqUUYZDqyuc2xLQ==} - - glsl-tokenizer@2.1.5: - resolution: {integrity: sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==} - - glslify-bundle@5.1.1: - resolution: {integrity: sha512-plaAOQPv62M1r3OsWf2UbjN0hUYAB7Aph5bfH58VxJZJhloRNbxOL9tl/7H71K7OLJoSJ2ZqWOKk3ttQ6wy24A==} - - glslify-deps@1.3.2: - resolution: {integrity: sha512-7S7IkHWygJRjcawveXQjRXLO2FTjijPDYC7QfZyAQanY+yGLCFHYnPtsGT9bdyHiwPTw/5a1m1M9hamT2aBpag==} - - glslify-loader@2.0.0: - resolution: {integrity: sha512-oOdmTX1BSPG75o3gNZToemfbbuN5dgi4Pco/aRfjbwGxPIfflYLuok6JCf2kDBPHjP+tV+imNsj6YRJg9gKJ1A==} - engines: {node: '>=6'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3621,9 +3468,6 @@ packages: is-yarn-global@0.3.0: resolution: {integrity: sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==} - isarray@0.0.1: - resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -3656,10 +3500,6 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} - jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true @@ -3685,9 +3525,6 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -3760,18 +3597,6 @@ packages: enquirer: optional: true - loader-runner@4.3.0: - resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} - engines: {node: '>=6.11.5'} - - loader-utils@1.4.2: - resolution: {integrity: sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==} - engines: {node: '>=4.0.0'} - - loader-utils@2.0.4: - resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} - engines: {node: '>=8.9.0'} - locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -3831,9 +3656,6 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} - map-limit@0.0.1: - resolution: {integrity: sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3862,9 +3684,6 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - merge-value@1.0.0: resolution: {integrity: sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ==} engines: {node: '>=0.10.0'} @@ -4015,9 +3834,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - murmurhash-js@1.0.0: - resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} - mux-embed@5.8.1: resolution: {integrity: sha512-OK379tvEtMaDubXYhwXKysp+HdBVspSuPuSlv0BdNV7Zl6TFVkMsXV2Wr5xmjBVCvSCCHJ9h3x3jF+2DQovC3g==} @@ -4046,9 +3862,6 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next-sitemap@4.2.3: resolution: {integrity: sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==} engines: {node: '>=14.18'} @@ -4142,9 +3955,6 @@ packages: ohash@1.1.6: resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==} - once@1.3.3: - resolution: {integrity: sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==} - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4478,15 +4288,6 @@ packages: react-dom: optional: true - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - - raw-loader@4.0.2: - resolution: {integrity: sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==} - engines: {node: '>= 10.13.0'} - peerDependencies: - webpack: ^4.0.0 || ^5.0.0 - rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -4603,9 +4404,6 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - readable-stream@1.0.34: - resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} - readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -4682,9 +4480,6 @@ packages: resolution: {integrity: sha512-+1lzwXehGCXSeryaISr6WujZzowloigEofRB+dj75y9RRa/obVcYgbHJd53tdYw8pvZj8GojXaaENws8Ktw/hQ==} engines: {node: '>=8'} - resolve@0.6.3: - resolution: {integrity: sha512-UHBY3viPlJKf85YijDUcikKX6tmF4SokIDp518ZDVT92JNDcG5uKIthaT/owt3Sar0lwtOafsQuwrg22/v2Dwg==} - resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -4749,10 +4544,6 @@ packages: scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} - schema-utils@3.3.0: - resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} - engines: {node: '>= 10.13.0'} - semver-diff@3.1.1: resolution: {integrity: sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==} engines: {node: '>=8'} @@ -4771,9 +4562,6 @@ packages: engines: {node: '>=10'} hasBin: true - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} @@ -4792,9 +4580,6 @@ packages: resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==} engines: {node: '>=0.10.0'} - shallow-copy@0.0.1: - resolution: {integrity: sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==} - sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4869,9 +4654,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -4932,9 +4714,6 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} - string_decoder@0.10.31: - resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -4997,10 +4776,6 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -5031,27 +4806,6 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} - terser-webpack-plugin@5.3.10: - resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true - - terser@5.37.0: - resolution: {integrity: sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==} - engines: {node: '>=10'} - hasBin: true - thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -5078,9 +4832,6 @@ packages: three@0.180.0: resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==} - through2@0.6.5: - resolution: {integrity: sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==} - tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -5280,10 +5031,6 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - watchpack@2.4.2: - resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} - engines: {node: '>=10.13.0'} - web-vitals@4.2.4: resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} @@ -5296,23 +5043,9 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webpack-sources@3.2.3: - resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} - engines: {node: '>=10.13.0'} - webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - webpack@5.97.1: - resolution: {integrity: sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==} - engines: {node: '>=10.13.0'} - hasBin: true - peerDependencies: - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -5410,14 +5143,6 @@ packages: engines: {node: '>= 0.10.0'} hasBin: true - xtend@2.2.0: - resolution: {integrity: sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==} - engines: {node: '>=0.4'} - - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - xycolors@0.1.2: resolution: {integrity: sha512-iUIDKoRUq/6Nfkiwv/PqxR6ENzgLkaaOeWwY54CtObpEwmvQHCvsgxd5xIGfEF/QU75H2quxIffOoU4tf2kKDg==} @@ -5684,10 +5409,6 @@ snapshots: '@chevrotain/utils@10.5.0': {} - '@choojs/findup@0.2.1': - dependencies: - commander: 2.20.3 - '@clack/core@0.3.5': dependencies: picocolors: 1.1.1 @@ -6213,11 +5934,6 @@ snapshots: '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.6': - dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 - '@jridgewell/sourcemap-codec@1.5.0': {} '@jridgewell/trace-mapping@0.3.25': @@ -7217,15 +6933,11 @@ snapshots: '@types/draco3d@1.4.10': {} - '@types/eslint-scope@3.7.7': - dependencies: - '@types/eslint': 9.6.1 - '@types/estree': 1.0.6 - '@types/eslint@9.6.1': dependencies: '@types/estree': 1.0.6 '@types/json-schema': 7.0.15 + optional: true '@types/estree-jsx@1.0.5': dependencies: @@ -7403,90 +7115,10 @@ snapshots: next: 15.6.0-canary.60(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) react: 19.0.1 - '@webassemblyjs/ast@1.14.1': - dependencies: - '@webassemblyjs/helper-numbers': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - - '@webassemblyjs/floating-point-hex-parser@1.13.2': {} - - '@webassemblyjs/helper-api-error@1.13.2': {} - - '@webassemblyjs/helper-buffer@1.14.1': {} - - '@webassemblyjs/helper-numbers@1.13.2': - dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.13.2 - '@webassemblyjs/helper-api-error': 1.13.2 - '@xtuc/long': 4.2.2 - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} - - '@webassemblyjs/helper-wasm-section@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/wasm-gen': 1.14.1 - - '@webassemblyjs/ieee754@1.13.2': - dependencies: - '@xtuc/ieee754': 1.2.0 - - '@webassemblyjs/leb128@1.13.2': - dependencies: - '@xtuc/long': 4.2.2 - - '@webassemblyjs/utf8@1.13.2': {} - - '@webassemblyjs/wasm-edit@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/helper-wasm-section': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-opt': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - '@webassemblyjs/wast-printer': 1.14.1 - - '@webassemblyjs/wasm-gen@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wasm-opt@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - - '@webassemblyjs/wasm-parser@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-api-error': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wast-printer@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@xtuc/long': 4.2.2 - '@webgpu/types@0.1.51': {} '@xstate/fsm@1.6.5': {} - '@xtuc/ieee754@1.2.0': {} - - '@xtuc/long@4.2.2': {} - accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -7498,10 +7130,6 @@ snapshots: acorn@8.14.0: {} - ajv-keywords@3.5.2(ajv@6.12.6): - dependencies: - ajv: 6.12.6 - ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -7701,8 +7329,6 @@ snapshots: dependencies: require-from-string: 2.0.2 - big.js@5.2.2: {} - binary-extensions@2.3.0: {} boxen@5.1.2: @@ -7736,8 +7362,6 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.4) - buffer-from@1.1.2: {} - buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -7815,8 +7439,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chrome-trace-event@1.0.4: {} - ci-info@2.0.0: {} ci-info@4.2.0: {} @@ -8064,8 +7686,6 @@ snapshots: emoji-regex@9.2.2: {} - emojis-list@3.0.0: {} - emulators@8.3.9: {} engine.io-client@6.6.3: @@ -8174,8 +7794,6 @@ snapshots: iterator.prototype: 1.1.3 safe-array-concat: 1.1.2 - es-module-lexer@1.5.4: {} - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -8427,11 +8045,6 @@ snapshots: dependencies: eslint: 9.23.0(jiti@1.21.6) - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - eslint-scope@8.3.0: dependencies: esrecurse: 4.3.0 @@ -8506,8 +8119,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: {} - estraverse@5.3.0: {} estree-util-is-identifier-name@3.0.0: {} @@ -8525,8 +8136,6 @@ snapshots: eventemitter3@5.0.1: {} - events@3.3.0: {} - ext@1.7.0: dependencies: type: 2.7.3 @@ -8733,8 +8342,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regexp@0.4.1: {} - glob@10.4.5: dependencies: foreground-child: 3.3.0 @@ -8777,79 +8384,8 @@ snapshots: glsl-constants@2.0.1: {} - glsl-inject-defines@1.0.3: - dependencies: - glsl-token-inject-block: 1.1.0 - glsl-token-string: 1.0.1 - glsl-tokenizer: 2.1.5 - glsl-noise@0.0.0: {} - glsl-resolve@0.0.1: - dependencies: - resolve: 0.6.3 - xtend: 2.2.0 - - glsl-token-assignments@2.0.2: {} - - glsl-token-defines@1.0.0: - dependencies: - glsl-tokenizer: 2.1.5 - - glsl-token-depth@1.1.2: {} - - glsl-token-descope@1.0.2: - dependencies: - glsl-token-assignments: 2.0.2 - glsl-token-depth: 1.1.2 - glsl-token-properties: 1.0.1 - glsl-token-scope: 1.1.2 - - glsl-token-inject-block@1.1.0: {} - - glsl-token-properties@1.0.1: {} - - glsl-token-scope@1.1.2: {} - - glsl-token-string@1.0.1: {} - - glsl-token-whitespace-trim@1.0.0: {} - - glsl-tokenizer@2.1.5: - dependencies: - through2: 0.6.5 - - glslify-bundle@5.1.1: - dependencies: - glsl-inject-defines: 1.0.3 - glsl-token-defines: 1.0.0 - glsl-token-depth: 1.1.2 - glsl-token-descope: 1.0.2 - glsl-token-scope: 1.1.2 - glsl-token-string: 1.0.1 - glsl-token-whitespace-trim: 1.0.0 - glsl-tokenizer: 2.1.5 - murmurhash-js: 1.0.0 - shallow-copy: 0.0.1 - - glslify-deps@1.3.2: - dependencies: - '@choojs/findup': 0.2.1 - events: 3.3.0 - glsl-resolve: 0.0.1 - glsl-tokenizer: 2.1.5 - graceful-fs: 4.2.11 - inherits: 2.0.4 - map-limit: 0.0.1 - resolve: 1.22.8 - - glslify-loader@2.0.0: - dependencies: - glslify-bundle: 5.1.1 - glslify-deps: 1.3.2 - loader-utils: 1.4.2 - resolve: 1.22.8 - gopd@1.2.0: {} graceful-fs@4.2.10: {} @@ -9128,8 +8664,6 @@ snapshots: is-yarn-global@0.3.0: {} - isarray@0.0.1: {} - isarray@1.0.0: {} isarray@2.0.5: {} @@ -9170,12 +8704,6 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jest-worker@27.5.1: - dependencies: - '@types/node': 20.0.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - jiti@1.21.6: {} jquery@3.7.1: {} @@ -9205,8 +8733,6 @@ snapshots: json-buffer@3.0.1: {} - json-parse-even-better-errors@2.3.1: {} - json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -9290,20 +8816,6 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 8.1.0 - loader-runner@4.3.0: {} - - loader-utils@1.4.2: - dependencies: - big.js: 5.2.2 - emojis-list: 3.0.0 - json5: 1.0.2 - - loader-utils@2.0.4: - dependencies: - big.js: 5.2.2 - emojis-list: 3.0.0 - json5: 2.2.3 - locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -9355,10 +8867,6 @@ snapshots: dependencies: semver: 6.3.1 - map-limit@0.0.1: - dependencies: - once: 1.3.3 - math-intrinsics@1.1.0: {} mdast-util-from-markdown@2.0.2: @@ -9450,8 +8958,6 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - merge-stream@2.0.0: {} - merge-value@1.0.0: dependencies: get-value: 2.0.6 @@ -9652,8 +9158,6 @@ snapshots: ms@2.1.3: {} - murmurhash-js@1.0.0: {} - mux-embed@5.8.1: {} mz@2.7.0: @@ -9674,8 +9178,6 @@ snapshots: negotiator@0.6.3: {} - neo-async@2.6.2: {} - next-sitemap@4.2.3(next@15.6.0-canary.60(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)): dependencies: '@corex/deepmerge': 4.0.43 @@ -9766,10 +9268,6 @@ snapshots: ohash@1.1.6: {} - once@1.3.3: - dependencies: - wrappy: 1.0.2 - once@1.4.0: dependencies: wrappy: 1.0.2 @@ -10032,16 +9530,6 @@ snapshots: - immer - use-sync-external-store - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - - raw-loader@4.0.2(webpack@5.97.1): - dependencies: - loader-utils: 2.0.4 - schema-utils: 3.3.0 - webpack: 5.97.1 - rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -10154,13 +9642,6 @@ snapshots: dependencies: pify: 2.3.0 - readable-stream@1.0.34: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 0.0.1 - string_decoder: 0.10.31 - readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -10243,8 +9724,6 @@ snapshots: dependencies: resolve-from: 5.0.0 - resolve@0.6.3: {} - resolve@1.22.8: dependencies: is-core-module: 2.15.1 @@ -10345,12 +9824,6 @@ snapshots: scheduler@0.25.0: {} - schema-utils@3.3.0: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) - semver-diff@3.1.1: dependencies: semver: 6.3.1 @@ -10361,10 +9834,6 @@ snapshots: semver@7.7.3: {} - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 - server-only@0.0.1: {} set-blocking@2.0.0: {} @@ -10392,8 +9861,6 @@ snapshots: is-plain-object: 2.0.4 split-string: 3.1.0 - shallow-copy@0.0.1: {} - sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -10530,11 +9997,6 @@ snapshots: source-map-js@1.2.1: {} - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - source-map@0.6.1: {} space-separated-tokens@2.0.2: {} @@ -10622,8 +10084,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - string_decoder@0.10.31: {} - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -10691,10 +10151,6 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} suspend-react@0.1.3(react@19.0.1): @@ -10743,22 +10199,6 @@ snapshots: tapable@2.2.1: {} - terser-webpack-plugin@5.3.10(webpack@5.97.1): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.37.0 - webpack: 5.97.1 - - terser@5.37.0: - dependencies: - '@jridgewell/source-map': 0.3.6 - acorn: 8.14.0 - commander: 2.20.3 - source-map-support: 0.5.21 - thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -10787,11 +10227,6 @@ snapshots: three@0.180.0: {} - through2@0.6.5: - dependencies: - readable-stream: 1.0.34 - xtend: 4.0.2 - tiny-invariant@1.3.3: {} tmp@0.2.3: {} @@ -11024,11 +10459,6 @@ snapshots: w3c-keyname@2.2.8: {} - watchpack@2.4.2: - dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - web-vitals@4.2.4: {} webgl-constants@1.1.1: {} @@ -11037,40 +10467,8 @@ snapshots: webidl-conversions@3.0.1: {} - webpack-sources@3.2.3: {} - webpack-virtual-modules@0.6.2: {} - webpack@5.97.1: - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.14.0 - browserslist: 4.24.4 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(webpack@5.97.1) - watchpack: 2.4.2 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - whatwg-fetch@3.6.20: {} whatwg-url@5.0.0: @@ -11171,10 +10569,6 @@ snapshots: commander: 2.20.3 cssfilter: 0.0.10 - xtend@2.2.0: {} - - xtend@4.0.2: {} - xycolors@0.1.2: {} y18n@4.0.3: {} diff --git a/src/components/arcade-board/button.tsx b/src/components/arcade-board/button.tsx index 53ba247e..35be71cc 100644 --- a/src/components/arcade-board/button.tsx +++ b/src/components/arcade-board/button.tsx @@ -1,4 +1,4 @@ -import { MeshDiscardMaterial } from "@react-three/drei" +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" import { animate } from "motion" import { memo, useCallback, useEffect, useMemo, useRef } from "react" diff --git a/src/components/arcade-board/stick.tsx b/src/components/arcade-board/stick.tsx index 16d14a93..337c45f4 100644 --- a/src/components/arcade-board/stick.tsx +++ b/src/components/arcade-board/stick.tsx @@ -1,4 +1,4 @@ -import { MeshDiscardMaterial } from "@react-three/drei" +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" import type { ThreeEvent } from "@react-three/fiber" import { animate } from "motion" import type { RefObject } from "react" diff --git a/src/components/blog-door/index.tsx b/src/components/blog-door/index.tsx index 7e671720..08054ace 100644 --- a/src/components/blog-door/index.tsx +++ b/src/components/blog-door/index.tsx @@ -1,4 +1,4 @@ -import { MeshDiscardMaterial } from "@react-three/drei" +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" import { track } from "@vercel/analytics" import { animate } from "motion" import posthog from "posthog-js" diff --git a/src/components/characters/character-instancer.tsx b/src/components/characters/character-instancer.tsx index f6a7e2ea..9375e4e7 100644 --- a/src/components/characters/character-instancer.tsx +++ b/src/components/characters/character-instancer.tsx @@ -59,8 +59,8 @@ function CharacterInstanceConfigInner() { const { fadeFactor } = useFadeAnimation() useFrameCallback(() => { - if (material) { - material.uniforms.fadeFactor.value = fadeFactor.current.get() + if (uniforms) { + uniforms.fadeFactor.value = fadeFactor.current.get() } }) @@ -70,17 +70,13 @@ function CharacterInstanceConfigInner() { return null } - const material = useMemo(() => { - // const bodyMapIndices = new Uint32Array(MAX_CHARACTERS_INSTANCES).fill(0) - // nodes.BODY.geometry.setAttribute("instanceMapIndex", new InstancedBufferAttribute(mapIndices, 1)) - + const { material, uniforms } = useMemo(() => { const bodyRepeat = 1 textureBody.repeat.set(1 / bodyRepeat, 1 / bodyRepeat) textureBody.anisotropy = 16 textureBody.flipY = false textureBody.updateMatrix() textureBody.needsUpdate = true - const material = createCharacterMaterial() textureFaces.repeat.set(1 / FACES_GRID_COLS, 1 / FACES_GRID_COLS) textureFaces.anisotropy = 16 @@ -100,45 +96,14 @@ function CharacterInstanceConfigInner() { textureComic.updateMatrix() textureComic.needsUpdate = true - /** Character material accepts having more than one instance - * each one can have a different map assigned - */ - interface MapConfig { - map: THREE.Texture - mapTransform: THREE.Matrix3 - } - const bodyMapConfig: MapConfig = { - map: textureBody, - mapTransform: textureBody.matrix - } - const headMapConfig: MapConfig = { - map: textureFaces, - mapTransform: textureFaces.matrix - } - const armsMapConfig: MapConfig = { - map: textureArms, - mapTransform: textureArms.matrix - } - const comicMapConfig: MapConfig = { - map: textureComic, - mapTransform: textureComic.matrix - } - const mapConfigs = [ - bodyMapConfig, - headMapConfig, - armsMapConfig, - comicMapConfig + { map: textureBody, mapTransform: textureBody.matrix }, + { map: textureFaces, mapTransform: textureFaces.matrix }, + { map: textureArms, mapTransform: textureArms.matrix }, + { map: textureComic, mapTransform: textureComic.matrix } ] - material.uniforms.mapConfigs = { - value: mapConfigs - } - material.defines = { - USE_MULTI_MAP: "", - USE_INSTANCED_LIGHT: "", - MULTI_MAP_COUNT: mapConfigs.length - } + const result = createCharacterMaterial(mapConfigs) // disable morph targets Object.keys(nodes).forEach((nodeKey) => { @@ -152,7 +117,7 @@ function CharacterInstanceConfigInner() { } }) - return material + return result // eslint-disable-next-line react-hooks/exhaustive-deps }, [textureBody, textureFaces, nodes]) diff --git a/src/components/characters/instanced-skinned-mesh/index.tsx b/src/components/characters/instanced-skinned-mesh/index.tsx index 9cb8e25c..1b4379e2 100644 --- a/src/components/characters/instanced-skinned-mesh/index.tsx +++ b/src/components/characters/instanced-skinned-mesh/index.tsx @@ -130,6 +130,8 @@ export const createInstancedSkinnedMesh = () => { }) } + instancer.setupMaterial() + useInstancedMesh.setState({ instancedMesh: instancer }) diff --git a/src/components/characters/instanced-skinned-mesh/instanced-skinned-mesh.ts b/src/components/characters/instanced-skinned-mesh/instanced-skinned-mesh.ts index 7fc2220a..5511bd0c 100644 --- a/src/components/characters/instanced-skinned-mesh/instanced-skinned-mesh.ts +++ b/src/components/characters/instanced-skinned-mesh/instanced-skinned-mesh.ts @@ -2,147 +2,6 @@ import * as THREE from "three" import { subscribable } from "@/lib/subscribable" -THREE.ShaderChunk.skinning_pars_vertex = - THREE.ShaderChunk.skinning_pars_vertex + - /* glsl */ ` - ivec2 calcCoord(int size, int id) { - int j = int(id); - int x = j % size; - int y = j / size; - return ivec2(x, y); - } - - ivec2 getSampleCoord(const sampler2D mapSampler, const float batchId) { - int size = textureSize(mapSampler, 0).x; - return calcCoord(size, int(batchId)); - } - - ivec2 getUSampleCoord(const usampler2D mapSampler, const float batchId) { - int size = textureSize(mapSampler, 0).x; - return calcCoord(size, int(batchId)); - } - - ivec2 getISampleCoord(const isampler2D mapSampler, const float batchId) { - int size = textureSize(mapSampler, 0).x; - return calcCoord(size, int(batchId)); - } - - #ifdef USE_BATCHED_SKINNING - - attribute vec4 skinIndex; - attribute vec4 skinWeight; - - uniform highp usampler2D batchingKeyframeTexture; - uniform highp sampler2D boneTexture; - - float getBatchedKeyframe( const in float batchId ) { - - int size = textureSize( batchingKeyframeTexture, 0 ).x; - int j = int ( batchId ); - int x = j % size; - int y = j / size; - return float( texelFetch( batchingKeyframeTexture, ivec2( x, y ), 0 ).r ); - - } - - mat4 getBatchedBoneMatrix( const in float i ) { - - float batchId = getIndirectIndex( gl_DrawID ); - float batchKeyframe = getBatchedKeyframe( batchId ); - - int size = textureSize( boneTexture, 0 ).x; - int j = int( batchKeyframe + i ) * 4; - int x = j % size; - int y = j / size; - vec4 v1 = texelFetch( boneTexture, ivec2( x, y ), 0 ); - vec4 v2 = texelFetch( boneTexture, ivec2( x + 1, y ), 0 ); - vec4 v3 = texelFetch( boneTexture, ivec2( x + 2, y ), 0 ); - vec4 v4 = texelFetch( boneTexture, ivec2( x + 3, y ), 0 ); - - return mat4( v1, v2, v3, v4 ); - - } - - #endif - - #ifdef USE_BATCHED_MORPHS - - uniform highp sampler2D morphDataTexture; - uniform highp isampler2D uActiveMorphs; - - attribute int vertexIndex; - - int getActiveMorphOffset() { - float batchId = getIndirectIndex(gl_DrawID); - ivec2 mapIndexCoord = getISampleCoord(uActiveMorphs, batchId); - return int(texelFetch(uActiveMorphs, mapIndexCoord, 0).x); - } - - vec4 getMorphTransform() { - int activeMorphOffset = getActiveMorphOffset(); - if(activeMorphOffset == -1) { - return vec4(0.0); - } - - ivec2 morphDataCoord = getSampleCoord(morphDataTexture, float(activeMorphOffset + vertexIndex)); - return texelFetch(morphDataTexture, morphDataCoord, 0); - } - - #endif - ` - -THREE.ShaderChunk.skinning_vertex = - THREE.ShaderChunk.skinning_vertex + - /* glsl */ ` - #ifdef USE_BATCHED_MORPHS - - transformed += getMorphTransform().xyz; - - #endif - - #ifdef USE_BATCHED_SKINNING - - vec4 skinVertex = vec4( transformed, 1.0 ); - - mat4 boneSkinMatrix = mat4( 0.0 ); - mat4 boneMatX = getBatchedBoneMatrix( skinIndex.x ); - mat4 boneMatY = getBatchedBoneMatrix( skinIndex.y ); - mat4 boneMatZ = getBatchedBoneMatrix( skinIndex.z ); - mat4 boneMatW = getBatchedBoneMatrix( skinIndex.w ); - - mat4 weightedBoneMatX = skinWeight.x * boneMatX; - mat4 weightedBoneMatY = skinWeight.y * boneMatY; - mat4 weightedBoneMatZ = skinWeight.z * boneMatZ; - mat4 weightedBoneMatW = skinWeight.w * boneMatW; - - boneSkinMatrix += weightedBoneMatX; - boneSkinMatrix += weightedBoneMatY; - boneSkinMatrix += weightedBoneMatZ; - boneSkinMatrix += weightedBoneMatW; - - vec3 objectNormal = normal; - - // apply the skeleton animation to the normal - vNormal = vec4(boneSkinMatrix * vec4(objectNormal, 0.0)).xyz; - - // get the mat transformation of the current instance - mat3 bm = mat3( batchingMatrix ); - - // apply the mat transformation to the normal - vNormal /= vec3( dot( bm[ 0 ], bm[ 0 ] ), dot( bm[ 1 ], bm[ 1 ] ), dot( bm[ 2 ], bm[ 2 ] ) ); - vNormal = normalize(bm * vNormal); - - vec4 skinned = vec4( 0.0 ); - skinned += weightedBoneMatX * skinVertex; - skinned += weightedBoneMatY * skinVertex; - skinned += weightedBoneMatZ * skinVertex; - skinned += weightedBoneMatW * skinVertex; - - transformed = skinned.xyz; - - #endif - ` - const _offsetMatrix = new THREE.Matrix4() interface InstanceParams { @@ -230,40 +89,34 @@ export class InstancedBatchedSkinnedMesh extends THREE.BatchedMesh { this.addInstancedUniform(name, defaultValue, type) }) } + } - this.material.onBeforeCompile = (shader) => { - if (this.boneTexture === null && this.useAnimations) - this.computeBoneTexture() - - if (this.shouldComputeMorphTargets) this.computeMorphTexture() - - shader.defines ??= {} - if (this.useAnimations) { - shader.defines.USE_BATCHED_SKINNING = "" - shader.uniforms.batchingKeyframeTexture = { - value: this.batchingKeyframeTexture - } - shader.uniforms.boneTexture = { value: this.boneTexture } - } - - if (this.useMorphs) { - shader.defines.USE_BATCHED_MORPHS = "" - shader.uniforms.morphDataTexture = { - value: this.morphTexture - } - } + /** Set up the TSL material with all mesh texture references */ + public setupMaterial(): void { + if (this.boneTexture === null && this.useAnimations) { + this.computeBoneTexture() + } + if (this.shouldComputeMorphTargets) { + this.computeMorphTexture() + } - this.dataTextures.forEach((texture, name) => { - if (!shader.uniforms[name]) { - shader.uniforms[name] = { value: texture } - } else { - shader.uniforms[name].value = texture - } + const setupSkinning = (this.material as any)._setupSkinning + if (typeof setupSkinning === "function") { + setupSkinning({ + boneTexture: this.boneTexture, + keyframeTexture: this.batchingKeyframeTexture, + indirectTexture: (this as any)._indirectTexture, + matricesTexture: (this as any)._matricesTexture, + morphTexture: this.morphTexture, + activeMorphsTexture: this.dataTextures.get("uActiveMorphs") ?? null, + dataTextures: this.dataTextures, + useAnimations: this.useAnimations, + useMorphs: this.useMorphs }) - - this.programCompiled = true - this.programCompiledSubscribable.runCallbacks() } + + this.programCompiled = true + this.programCompiledSubscribable.runCallbacks() } private programCompiled = false diff --git a/src/components/christmas-tree/client.tsx b/src/components/christmas-tree/client.tsx index 7d286607..31de5969 100644 --- a/src/components/christmas-tree/client.tsx +++ b/src/components/christmas-tree/client.tsx @@ -1,4 +1,6 @@ -import { MeshDiscardMaterial, useTexture } from "@react-three/drei" +import { useTexture } from "@react-three/drei" + +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" import { useFrame } from "@react-three/fiber" import { useCallback, useEffect, useRef } from "react" import { Color, Mesh, ShaderMaterial, Vector3 } from "three" diff --git a/src/components/clock/index.tsx b/src/components/clock/index.tsx index b6226c98..d60ade51 100644 --- a/src/components/clock/index.tsx +++ b/src/components/clock/index.tsx @@ -1,4 +1,4 @@ -import { MeshDiscardMaterial } from "@react-three/drei" +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" import { useEffect, useRef, useState } from "react" import { Mesh } from "three" diff --git a/src/components/doom-js/crt-mesh.tsx b/src/components/doom-js/crt-mesh.tsx index 40d12bf9..00d6fb04 100644 --- a/src/components/doom-js/crt-mesh.tsx +++ b/src/components/doom-js/crt-mesh.tsx @@ -80,21 +80,21 @@ const createCRTMaterial = () => { const distortedUv = barrelDistortionFn(vUv, uCurvature) // Branchless bounds check: 1 when inside [0,1], 0 when outside - const inBounds = step(float(0.0), distortedUv.x) - .mul(step(distortedUv.x, float(1.0))) - .mul(step(float(0.0), distortedUv.y)) - .mul(step(distortedUv.y, float(1.0))) + const inBounds = float(step(float(0.0), distortedUv.x)) + .mul(float(step(distortedUv.x, float(1.0)))) + .mul(float(step(float(0.0), distortedUv.y))) + .mul(float(step(distortedUv.y, float(1.0)))) // Chromatic aberration const aberrationAmount = float(0.01) const distFromCenter = vUv.sub(0.5) const r = uTexture - .uv(distortedUv.sub(distFromCenter.mul(aberrationAmount))) + .sample(distortedUv.sub(distFromCenter.mul(aberrationAmount))) .r - const g = uTexture.uv(distortedUv).g + const g = uTexture.sample(distortedUv).g const b = uTexture - .uv(distortedUv.add(distFromCenter.mul(aberrationAmount))) + .sample(distortedUv.add(distFromCenter.mul(aberrationAmount))) .b const color = vec3(r, g, b).toVar() diff --git a/src/components/inspectables/inspectable.tsx b/src/components/inspectables/inspectable.tsx index 26256ba0..5f2c5b60 100644 --- a/src/components/inspectables/inspectable.tsx +++ b/src/components/inspectables/inspectable.tsx @@ -1,6 +1,6 @@ "use client" -import { MeshDiscardMaterial } from "@react-three/drei" +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" import { useThree } from "@react-three/fiber" import { track } from "@vercel/analytics" import { animate, MotionValue } from "motion" diff --git a/src/components/lamp/index.tsx b/src/components/lamp/index.tsx index bdd753c2..c2df534c 100644 --- a/src/components/lamp/index.tsx +++ b/src/components/lamp/index.tsx @@ -1,13 +1,15 @@ -import { MeshDiscardMaterial } from "@react-three/drei" -import { extend, useLoader, useThree } from "@react-three/fiber" +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" +import { useLoader } from "@react-three/fiber" import { BallCollider, RigidBody, useRopeJoint } from "@react-three/rapier" import { track } from "@vercel/analytics" -import { MeshLineGeometry, MeshLineMaterial } from "meshline" import { animate } from "motion" import posthog from "posthog-js" import { memo, useEffect, useMemo, useRef, useState } from "react" import * as THREE from "three" import { EXRLoader } from "three/examples/jsm/Addons.js" +import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js" +import { Line2 } from "three/examples/jsm/lines/webgpu/Line2.js" +import { Line2NodeMaterial } from "three/webgpu" import { useAssets } from "@/components/assets-provider" import { useInspectable } from "@/components/inspectables/context" @@ -18,8 +20,6 @@ import { useFrameCallback } from "@/hooks/use-pausable-time" import { useSiteAudio } from "@/hooks/use-site-audio" import { createGlobalShaderMaterial } from "@/shaders/material-global-shader" -extend({ MeshLineGeometry, MeshLineMaterial }) - const colorWhenOn = new THREE.Color("#f2f2f2") const colorWhenOff = new THREE.Color("#595959") const colorWhenInspecting = new THREE.Color("#000000") @@ -75,7 +75,16 @@ export const Lamp = memo(function LampInner() { return material }, []) - const { width, height } = useThree((state) => state.size) + const { bandLine, bandGeometry } = useMemo(() => { + const geo = new LineGeometry() + const mat = new Line2NodeMaterial() + mat.color.copy(colorWhenOn) + ;(mat as any).lineWidth = 0.005 + ;(mat as any).worldUnits = true + const line = new Line2(geo, mat) + return { bandLine: line, bandGeometry: geo } + }, []) + const curve = useMemo( () => new THREE.CatmullRomCurve3([ @@ -169,7 +178,12 @@ export const Lamp = memo(function LampInner() { curve.points[1].copy(j2Pos) curve.points[2].copy(j1Pos) curve.points[3].copy(j0Pos) - band.current.geometry.setPoints(curve.getPoints(8)) + const points = curve.getPoints(8) + const positions: number[] = [] + for (const p of points) { + positions.push(p.x, p.y, p.z) + } + bandGeometry.setPositions(positions) const tension_j0_j1 = tension(j0Pos, j1Pos) const tension_j1_j2 = tension(j1Pos, j2Pos) @@ -232,8 +246,7 @@ export const Lamp = memo(function LampInner() { for (const target of lampTargets) { if (target instanceof THREE.Mesh) { // @ts-ignore - target.material.uniforms.lightLampEnabled.value = light - // @ts-ignore + target.material.uniforms.lightLampEnabled.value = light ? 1 : 0 } } } @@ -332,18 +345,8 @@ export const Lamp = memo(function LampInner() { {lamp && } - - {/* @ts-ignore */} - - {/* @ts-ignore */} - - - {lamp && } - + + {lamp && } ) }) diff --git a/src/components/locked-door/index.tsx b/src/components/locked-door/index.tsx index 1dd08c6c..384adf5b 100644 --- a/src/components/locked-door/index.tsx +++ b/src/components/locked-door/index.tsx @@ -1,4 +1,4 @@ -import { MeshDiscardMaterial } from "@react-three/drei" +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" import { track } from "@vercel/analytics" import { animate } from "motion" import posthog from "posthog-js" diff --git a/src/components/map/bakes.tsx b/src/components/map/bakes.tsx index cc33e8d8..f770d8ea 100644 --- a/src/components/map/bakes.tsx +++ b/src/components/map/bakes.tsx @@ -53,7 +53,7 @@ const addMatcap = (update: TextureUpdate, isGlass: boolean) => { if (!update.mesh.userData.hasGlobalMaterial) return const material = update.mesh.material as ShaderMaterial material.uniforms.matcap.value = update.texture - material.uniforms.glassMatcap.value = isGlass + material.uniforms.glassMatcap.value = isGlass ? 1 : 0 } const addReflex = (update: TextureUpdate) => { diff --git a/src/components/map/index.tsx b/src/components/map/index.tsx index 55b52b0f..142c116d 100644 --- a/src/components/map/index.tsx +++ b/src/components/map/index.tsx @@ -190,7 +190,8 @@ export const Map = memo(() => { MATCAP: withMatcap !== undefined, VIDEO: withVideo !== undefined, CLOUDS: isClouds, - DAYLIGHT: isDaylight + DAYLIGHT: isDaylight, + IS_LOBO_MARINO: meshChild.name === "SM_Lobo" } const newMaterials = Array.isArray(currentMaterial) diff --git a/src/components/map/use-frame-loop.ts b/src/components/map/use-frame-loop.ts index 9888f197..3265c647 100644 --- a/src/components/map/use-frame-loop.ts +++ b/src/components/map/use-frame-loop.ts @@ -11,7 +11,7 @@ export const useFrameLoop = () => { Object.values(shaderMaterial).forEach((material) => { material.uniforms.uTime.value += delta - material.uniforms.inspectingEnabled.value = inspectingEnabled.current + material.uniforms.inspectingEnabled.value = inspectingEnabled.current ? 1 : 0 material.uniforms.fadeFactor.value = fadeFactor.current.get() }) diff --git a/src/components/mesh-discard-material.tsx b/src/components/mesh-discard-material.tsx new file mode 100644 index 00000000..cf8aaab4 --- /dev/null +++ b/src/components/mesh-discard-material.tsx @@ -0,0 +1,11 @@ +"use client" + +import { NodeMaterial } from "three/webgpu" + +const _material = new NodeMaterial() +_material.colorWrite = false +_material.depthWrite = false + +export function MeshDiscardMaterial() { + return +} diff --git a/src/components/pets/index.tsx b/src/components/pets/index.tsx index 52c7c1de..b4c78f7b 100644 --- a/src/components/pets/index.tsx +++ b/src/components/pets/index.tsx @@ -1,8 +1,6 @@ -import { - MeshDiscardMaterial, - useAnimations, - useTexture -} from "@react-three/drei" +import { useAnimations, useTexture } from "@react-three/drei" + +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" import { track } from "@vercel/analytics" import posthog from "posthog-js" import { useEffect, useMemo } from "react" diff --git a/src/components/routing-element/routing-arrow.tsx b/src/components/routing-element/routing-arrow.tsx index 042abace..97236199 100644 --- a/src/components/routing-element/routing-arrow.tsx +++ b/src/components/routing-element/routing-arrow.tsx @@ -1,6 +1,19 @@ import { useThree } from "@react-three/fiber" -import { useRef } from "react" -import type { Mesh } from "three" +import { useMemo, useRef } from "react" +import { DoubleSide, type Mesh } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Discard, + Fn, + float, + floor, + mod, + screenCoordinate, + step, + uv, + vec2, + vec4 +} from "three/tsl" import { useFrameCallback } from "@/hooks/use-pausable-time" @@ -30,6 +43,36 @@ export const RoutingArrow = ({ } }) + const material = useMemo(() => { + const mat = new NodeMaterial() + mat.transparent = true + mat.side = DoubleSide + mat.depthWrite = false + mat.depthTest = false + + const pixelDensity = float(12.0) + const gridSize = vec2(pixelDensity, pixelDensity.mul(1.5)) + const grid = floor(uv().mul(gridSize)) + const gridUv = grid.div(gridSize) + + const p = gridUv.mul(2.0).sub(1.0) + const triangle = float(step(p.x.abs().mul(2.0).sub(0.9), p.y)).mul( + float(step(p.y, float(0.9))) + ) + + const checkerPos = floor(screenCoordinate.xy.mul(0.5)) + const checker = mod(checkerPos.x.add(checkerPos.y), float(2.0)) + + const shouldDiscard = checker.lessThan(0.5).or(triangle.equal(0.0)) + + mat.colorNode = Fn(() => { + Discard(shouldDiscard) + return vec4(1.0, 1.0, 1.0, 1.0) + })() + + return mat + }, []) + return ( - + ) } diff --git a/src/components/routing-element/routing-plus.tsx b/src/components/routing-element/routing-plus.tsx index c34228af..e7f98ad8 100644 --- a/src/components/routing-element/routing-plus.tsx +++ b/src/components/routing-element/routing-plus.tsx @@ -1,66 +1,74 @@ -import { memo, useMemo } from "react" -import { BufferGeometry, Float32BufferAttribute } from "three" +import { useThree } from "@react-three/fiber" +import { memo, useMemo, useRef } from "react" +import { + BufferGeometry, + InstancedMesh as InstancedMeshType, + Object3D, + PlaneGeometry +} from "three" +import { NodeMaterial } from "three/webgpu" +import { Discard, Fn, float, uv, vec4 } from "three/tsl" + +import { useFrameCallback } from "@/hooks/use-pausable-time" + +const POINT_SIZE = 0.04 +const dummy = new Object3D() const RoutingPlusInner = ({ geometry }: { geometry: BufferGeometry }) => { - const pointsGeometry = useMemo(() => { + const meshRef = useRef(null) + const camera = useThree((state) => state.camera) + + const { planeGeo, material, count, positionsArray } = useMemo(() => { if (!geometry.attributes.position || !geometry.attributes.normal) - return null + return { planeGeo: null, material: null, count: 0, positionsArray: null } - const pointsGeo = new BufferGeometry() + const count = geometry.attributes.position.count + const positionsArray = geometry.attributes.position.array as Float32Array - const positions = [] - const colors = [] - const whiteColor = [1, 1, 1] + const planeGeo = new PlaneGeometry(POINT_SIZE, POINT_SIZE) - for (let i = 0; i < geometry.attributes.position.count; i++) { - const x = geometry.attributes.position.array[i * 3] - const y = geometry.attributes.position.array[i * 3 + 1] - const z = geometry.attributes.position.array[i * 3 + 2] + const mat = new NodeMaterial() + mat.depthTest = false + mat.depthWrite = false + mat.transparent = true - positions.push(x, y, z) - colors.push(...whiteColor) - } + // Cross/plus pattern using UVs (replaces gl_PointCoord) + const coord = uv().mul(2.0).sub(1.0) + const thickness = float(0.25) + const isCross = coord.x + .abs() + .lessThan(thickness) + .or(coord.y.abs().lessThan(thickness)) - pointsGeo.setAttribute("position", new Float32BufferAttribute(positions, 3)) - pointsGeo.setAttribute("color", new Float32BufferAttribute(colors, 3)) + mat.colorNode = Fn(() => { + Discard(isCross.not()) + return vec4(1.0, 1.0, 1.0, 1.0) + })() - return pointsGeo + return { planeGeo, material: mat, count, positionsArray } }, [geometry]) - // Shader code - const vertexShader = ` - void main() { - vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); - gl_PointSize = 8.0; - gl_Position = projectionMatrix * mvPosition; - } - ` - - const fragmentShader = ` - void main() { - vec2 coord = gl_PointCoord * 2.0 - 1.0; - float thickness = 0.25; - - if (abs(coord.x) < thickness || abs(coord.y) < thickness) { - gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); - } else { - discard; - } + // Billboard each instance to face the camera (points are always screen-facing) + useFrameCallback(() => { + if (!meshRef.current || !positionsArray) return + + for (let i = 0; i < count; i++) { + dummy.position.set( + positionsArray[i * 3], + positionsArray[i * 3 + 1], + positionsArray[i * 3 + 2] + ) + dummy.quaternion.copy(camera.quaternion) + dummy.updateMatrix() + meshRef.current.setMatrixAt(i, dummy.matrix) } - ` - - return pointsGeometry ? ( - - - - - ) : null + + meshRef.current.instanceMatrix.needsUpdate = true + }) + + if (!planeGeo || !material || count === 0) return null + + return } export const RoutingPlus = memo(RoutingPlusInner) diff --git a/src/components/sparkles/index.tsx b/src/components/sparkles/index.tsx index 8a2d6e1a..989b595f 100644 --- a/src/components/sparkles/index.tsx +++ b/src/components/sparkles/index.tsx @@ -82,7 +82,7 @@ export const Sparkle = (props: SparklesProps) => { const cycle = mod(uTime.mul(aSpeed).add(seed.mul(10.0)), 10.0) const fadeIn = smoothstep(0.0, 0.3, cycle) const fadeOut = smoothstep(1.0, 0.7, cycle) - const pulse = step(cycle, 1.0).mul(fadeIn).mul(fadeOut) + const pulse = float(step(cycle, 1.0)).mul(fadeIn).mul(fadeOut) return clamp(aOpacity.mul(pulse), 0.0, 1.0) .mul(0.5) .mul(float(1.0).sub(uFadeFactor)) diff --git a/src/components/speaker-hover/index.tsx b/src/components/speaker-hover/index.tsx index 3d380f55..ddcd8401 100644 --- a/src/components/speaker-hover/index.tsx +++ b/src/components/speaker-hover/index.tsx @@ -1,4 +1,4 @@ -import { MeshDiscardMaterial } from "@react-three/drei" +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" import { track } from "@vercel/analytics" import posthog from "posthog-js" import { useCallback, useEffect, useState } from "react" diff --git a/src/components/weather/index.tsx b/src/components/weather/index.tsx index a5b14f94..8448b0d3 100644 --- a/src/components/weather/index.tsx +++ b/src/components/weather/index.tsx @@ -135,9 +135,6 @@ function LoboMarino({ loboMarino }: { loboMarino: Mesh }) { const mat = loboMarino?.material as ShaderMaterial mat.uniforms.uColor.value = currentLoboColor - mat.defines.IS_LOBO_MARINO = true - mat.needsUpdate = true - return mat }, [loboMarino, currentLoboColor]) diff --git a/src/declarations/glsl.d.ts b/src/declarations/glsl.d.ts deleted file mode 100644 index 6086e50b..00000000 --- a/src/declarations/glsl.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*.glsl" { - const content: string - export default content -} diff --git a/src/declarations/shader.d.ts b/src/declarations/shader.d.ts deleted file mode 100644 index 3215bb99..00000000 --- a/src/declarations/shader.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare module "*.vert" { - const content: string - export default content -} - -declare module "*.frag" { - const content: string - export default content -} diff --git a/src/declarations/three-tsl.d.ts b/src/declarations/three-tsl.d.ts new file mode 100644 index 00000000..dac70f92 --- /dev/null +++ b/src/declarations/three-tsl.d.ts @@ -0,0 +1,8 @@ +import type Node from "three/src/nodes/core/Node.js" +import type { NodeRepresentation, ShaderNodeObject } from "three/src/nodes/tsl/TSLCore.js" + +declare module "three/src/nodes/accessors/TextureNode.js" { + export default interface TextureNode { + sample(uvNode: NodeRepresentation): ShaderNodeObject + } +} diff --git a/src/shaders/material-characters/fragment.glsl b/src/shaders/material-characters/fragment.glsl deleted file mode 100644 index 34aa3cd6..00000000 --- a/src/shaders/material-characters/fragment.glsl +++ /dev/null @@ -1,97 +0,0 @@ -varying vec2 vUv; -varying vec3 vNormal; -varying vec2 vMapOffset; -varying vec3 vDebug; -varying vec4 vLightColor; -varying vec4 vLightDirection; -varying vec4 vPointLightPosition; -varying vec4 vPointLightColor; -varying vec3 vWorldPosition; -uniform float fadeFactor; - -#ifdef USE_MULTI_MAP -struct MapConfig { - sampler2D map; - mat3 mapTransform; -}; -uniform MapConfig mapConfigs[MULTI_MAP_COUNT]; -varying float vMapIndex; - -vec4 sampleConfigMap(sampler2D map, mat3 mapTransform) { - vec2 mapUv = (mapTransform * vec3(vUv, 1.0)).xy; - mapUv += vMapOffset; - return texture2D(map, mapUv); -} -#endif - -vec3 gamma(vec3 color, float gamma) { - return pow(color, vec3(gamma)); -} - -float valueRemap(float value, float min, float max, float newMin, float newMax) { - return newMin + (value - min) * (newMax - newMin) / (max - min); -} - -void main() { - vec3 normal = normalize(vNormal); - vec3 color = vec3(0.0); - - vec4 mapSample = vec4(0.0); - float alpha = 1.0; - #ifdef USE_MULTI_MAP //support only 4 maps - #if MULTI_MAP_COUNT >= 1 - if (vMapIndex < 0.5) mapSample = sampleConfigMap(mapConfigs[0].map, mapConfigs[0].mapTransform); - #endif - #if MULTI_MAP_COUNT >= 2 - else if (vMapIndex < 1.5) mapSample = sampleConfigMap(mapConfigs[1].map, mapConfigs[1].mapTransform); - #endif - #if MULTI_MAP_COUNT >= 3 - else if (vMapIndex < 2.5) mapSample = sampleConfigMap(mapConfigs[2].map, mapConfigs[2].mapTransform); - #endif - #if MULTI_MAP_COUNT >= 4 - else if (vMapIndex < 3.5) mapSample = sampleConfigMap(mapConfigs[3].map, mapConfigs[3].mapTransform); - #endif - - color = mapSample.rgb; - alpha *= mapSample.a; - #endif - - color = gamma(color, 2.2); - - vec3 baseColor = color; - - float lightIntensity = dot(vLightDirection.xyz, normal); - lightIntensity = valueRemap(lightIntensity, -0.5, 1.0, 0.0, 1.0); - lightIntensity = clamp(lightIntensity, 0.0, 1.0); - lightIntensity *= 2.; - // ambient light - lightIntensity += 0.1; - - color *= lightIntensity; - color *= (vLightColor.rgb * vLightColor.a); - - - float lightRadius = vPointLightPosition.w; - vec3 relativeLight = vPointLightPosition.xyz - vWorldPosition; - vec3 lightDir = normalize(relativeLight); - float lightDecay = 1.0 - length(relativeLight) / lightRadius; - lightDecay = clamp(lightDecay, 0.0, 1.0); - lightDecay = pow(lightDecay, 2.); - float lightFactor = clamp(dot(lightDir, normal), 0.0, 1.0); - lightFactor *= lightDecay; - - vec3 pointLightColor = vPointLightColor.rgb * vPointLightColor.a; - pointLightColor *= lightFactor; - pointLightColor *= baseColor; // absorved light by base color - - color += pointLightColor; - - - if(alpha < 0.8) discard; - - color *= 1.0 - fadeFactor; - - gl_FragColor = vec4(vec3(color), 1.); - - // gl_FragColor = vec4(vDebug, 1.0); -} diff --git a/src/shaders/material-characters/index.ts b/src/shaders/material-characters/index.ts index 207aae13..8655a270 100644 --- a/src/shaders/material-characters/index.ts +++ b/src/shaders/material-characters/index.ts @@ -1,18 +1,390 @@ -import { ShaderMaterial } from "three" +import type { DataTexture, Matrix3, Texture } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + int, + vec2, + vec3, + vec4, + ivec2, + uniform, + texture as tslTexture, + textureLoad, + textureSize, + uv, + instanceIndex, + attribute, + varying, + mat4, + normalize, + clamp, + pow, + dot, + length, + step, + select, + max, + positionLocal +} from "three/tsl" -import fragmentShader from "./fragment.glsl" -import vertexShader from "./vertex.glsl" +interface MapConfig { + map: Texture + mapTransform: Matrix3 +} -export const createCharacterMaterial = () => - new ShaderMaterial({ +export interface SkinningConfig { + boneTexture: DataTexture | null + keyframeTexture: DataTexture + indirectTexture: DataTexture + matricesTexture: DataTexture + morphTexture: DataTexture | null + activeMorphsTexture: DataTexture | null + dataTextures: Map + useAnimations: boolean + useMorphs: boolean +} + +export const createCharacterMaterial = (mapConfigs?: MapConfig[]) => { + const material = new NodeMaterial() + + const uFadeFactor = uniform(0) + + // Map texture uniforms (set at creation time) + const mapCount = mapConfigs?.length ?? 0 + const uMapTextures = mapConfigs?.map((c) => tslTexture(c.map)) ?? [] + const uMapTransforms = + mapConfigs?.map((c) => uniform(c.mapTransform)) ?? [] + + // --- Varyings (assigned in positionNode, read in colorNode) --- + const vNormal = varying(vec3(0, 0, 0), "v_normal") + const vWorldPosition = varying(vec3(0, 0, 0), "v_worldPosition") + const vMapIndex = varying(float(0), "v_mapIndex") + const vMapOffset = varying(vec2(0, 0), "v_mapOffset") + const vLightColor = varying(vec4(0, 0, 0, 0), "v_lightColor") + const vLightDirection = varying(vec4(0, 0, 0, 0), "v_lightDirection") + const vPointLightPosition = varying( + vec4(0, 0, 0, 0), + "v_pointLightPosition" + ) + const vPointLightColor = varying(vec4(0, 0, 0, 0), "v_pointLightColor") + + // --- Fragment shader helper --- + const valueRemapFn = /* @__PURE__ */ Fn( + ([value, inMin, inMax, outMin, outMax]: [any, any, any, any, any]) => { + return float(outMin).add( + float(value) + .sub(inMin) + .mul(float(outMax).sub(outMin)) + .div(float(inMax).sub(inMin)) + ) + } + ) + + // --- Fragment shader (colorNode) --- + const result = Fn(() => { + const vUv = uv() + const normal = normalize(vNormal) + + // Multi-map sampling + const mapSample = vec4(0, 0, 0, 1).toVar() + + if (mapCount > 0) { + const samples = uMapTextures.map((tex, i) => { + const mapUv = uMapTransforms[i] + .mul(vec3(vUv.x, vUv.y, float(1.0))) + .xy.add(vMapOffset) + return tex.sample(mapUv) + }) + + if (samples.length === 1) { + mapSample.assign(samples[0]) + } else if (samples.length === 2) { + mapSample.assign( + select(vMapIndex.lessThan(0.5), samples[0], samples[1]) + ) + } else if (samples.length === 3) { + mapSample.assign( + select( + vMapIndex.lessThan(0.5), + samples[0], + select(vMapIndex.lessThan(1.5), samples[1], samples[2]) + ) + ) + } else if (samples.length >= 4) { + mapSample.assign( + select( + vMapIndex.lessThan(0.5), + samples[0], + select( + vMapIndex.lessThan(1.5), + samples[1], + select(vMapIndex.lessThan(2.5), samples[2], samples[3]) + ) + ) + ) + } + } + + // Gamma correction (sRGB to linear) + const color = pow(mapSample.rgb, vec3(2.2, 2.2, 2.2)).toVar() + const alpha = mapSample.a + + const baseColor = vec3(color.x, color.y, color.z) + + // Directional light + const lightIntensity = valueRemapFn( + dot(vLightDirection.xyz, normal), + float(-0.5), + float(1.0), + float(0.0), + float(1.0) + ).toVar() + lightIntensity.assign(clamp(lightIntensity, 0.0, 1.0)) + lightIntensity.mulAssign(2.0) + lightIntensity.addAssign(0.1) // ambient + + color.mulAssign(lightIntensity) + color.mulAssign(vLightColor.rgb.mul(vLightColor.a)) + + // Point light + const lightRadius = vPointLightPosition.w + const relativeLight = vPointLightPosition.xyz.sub(vWorldPosition) + const lightDir = normalize(relativeLight) + const lightDecay = clamp( + float(1.0).sub(length(relativeLight).div(lightRadius)), + 0.0, + 1.0 + ).toVar() + lightDecay.assign(pow(lightDecay, float(2.0))) + const lightFactor = clamp(dot(lightDir, normal), 0.0, 1.0).mul(lightDecay) + + const pointLightColor = vPointLightColor.rgb + .mul(vPointLightColor.a) + .mul(lightFactor) + .mul(baseColor) + color.addAssign(pointLightColor) + + // Alpha test + alpha.lessThan(0.8).discard() + + // Fade + color.mulAssign(float(1.0).sub(uFadeFactor)) + + return vec4(color, float(1.0)) + })() + + material.colorNode = result.rgb + material.opacityNode = result.a + + // --- Skinning setup function (called after mesh textures are ready) --- + const setupSkinning = (config: SkinningConfig) => { + const { + boneTexture, + keyframeTexture, + indirectTexture, + matricesTexture, + morphTexture, + activeMorphsTexture, + dataTextures, + useAnimations, + useMorphs + } = config + + // Helper: calculate 2D coord from 1D index for a given texture + const calcCoord = (tex: DataTexture, id: any) => { + const size = int(textureSize(textureLoad(tex), int(0)).x) + const x = int(id).mod(size) + const y = int(id).div(size) + return ivec2(x, y) + } + + // Helper: get indirect batch index + const getIndirectIndex = (id: any) => { + return textureLoad(indirectTexture, calcCoord(indirectTexture, id)).x + } + + // Helper: get batching matrix for an instance + const getBatchingMatrixFn = /* @__PURE__ */ Fn(([batchId]: [any]) => { + const size = int(textureSize(textureLoad(matricesTexture), int(0)).x) + const j = int(batchId).mul(4) + const x = j.mod(size) + const y = j.div(size) + const v1 = textureLoad(matricesTexture, ivec2(x, y)) + const v2 = textureLoad(matricesTexture, ivec2(x.add(1), y)) + const v3 = textureLoad(matricesTexture, ivec2(x.add(2), y)) + const v4 = textureLoad(matricesTexture, ivec2(x.add(3), y)) + return mat4(v1, v2, v3, v4) + }) + + const positionFn = Fn(() => { + // Get batch index via indirect lookup. + // WebGPU doesn't support drawIndex (WGSLNodeBuilder.getDrawIndex() returns null), + // so we use instanceIndex which is what Three.js's BatchNode also uses for WebGPU. + // On WebGL2 fallback with WEBGL_multi_draw, drawIndex (gl_DrawID) would be needed, + // but the WebGL2 fallback's BatchNode also handles this switch automatically. + const batchId = getIndirectIndex(instanceIndex) + + // --- Read per-instance data textures --- + const mapIndexTex = dataTextures.get("uMapIndex") + if (mapIndexTex) { + vMapIndex.assign( + float(textureLoad(mapIndexTex, calcCoord(mapIndexTex, batchId)).x) + ) + } + + const mapOffsetTex = dataTextures.get("uMapOffset") + if (mapOffsetTex) { + vMapOffset.assign( + textureLoad(mapOffsetTex, calcCoord(mapOffsetTex, batchId)).xy + ) + } + + const lightDirTex = dataTextures.get("uLightDirection") + if (lightDirTex) { + vLightDirection.assign( + textureLoad(lightDirTex, calcCoord(lightDirTex, batchId)) + ) + } + + const lightColorTex = dataTextures.get("uLightColor") + if (lightColorTex) { + vLightColor.assign( + textureLoad(lightColorTex, calcCoord(lightColorTex, batchId)) + ) + } + + const ptLightPosTex = dataTextures.get("uPointLightPosition") + if (ptLightPosTex) { + vPointLightPosition.assign( + textureLoad(ptLightPosTex, calcCoord(ptLightPosTex, batchId)) + ) + } + + const ptLightColorTex = dataTextures.get("uPointLightColor") + if (ptLightColorTex) { + vPointLightColor.assign( + textureLoad(ptLightColorTex, calcCoord(ptLightColorTex, batchId)) + ) + } + + // --- Position computation --- + // Read raw position/normal from geometry attributes (bypasses BatchNode) + const pos = attribute("position", "vec3").toVar() + const normal = attribute("normal", "vec3").toVar() + + // Apply morph targets + if (useMorphs && morphTexture && activeMorphsTexture) { + const vertexIdx = attribute("vertexIndex", "int") + const morphOffsetRaw = textureLoad( + activeMorphsTexture, + calcCoord(activeMorphsTexture, batchId) + ).x + // morphOffset is -1 when disabled, >= 0 when active + const isActive = float(step(float(0), float(morphOffsetRaw))) + // Clamp to 0 minimum to avoid invalid texture coords + const safeMorphOffset = max(int(morphOffsetRaw), int(0)) + const morphCoord = calcCoord( + morphTexture, + float(safeMorphOffset).add(float(vertexIdx)) + ) + const morphDelta = textureLoad(morphTexture, morphCoord).xyz + pos.addAssign(morphDelta.mul(isActive)) + } + + // Apply bone skinning + if (useAnimations && boneTexture) { + const skinIndex = attribute("skinIndex", "vec4") + const skinWeight = attribute("skinWeight", "vec4") + + // Get keyframe offset for this batch instance + const keyframe = float( + textureLoad( + keyframeTexture, + calcCoord(keyframeTexture, batchId) + ).x + ) + + // Read bone matrix: 4 consecutive texels per bone + const boneTexSize = int( + textureSize(textureLoad(boneTexture), int(0)).x + ) + const getBoneMatrix = (boneIdx: any) => { + const j = int(keyframe.add(boneIdx)).mul(4) + const x = j.mod(boneTexSize) + const y = j.div(boneTexSize) + const v1 = textureLoad(boneTexture, ivec2(x, y)) + const v2 = textureLoad(boneTexture, ivec2(x.add(1), y)) + const v3 = textureLoad(boneTexture, ivec2(x.add(2), y)) + const v4 = textureLoad(boneTexture, ivec2(x.add(3), y)) + return mat4(v1, v2, v3, v4) + } + + const boneMat0 = getBoneMatrix(skinIndex.x) + const boneMat1 = getBoneMatrix(skinIndex.y) + const boneMat2 = getBoneMatrix(skinIndex.z) + const boneMat3 = getBoneMatrix(skinIndex.w) + + // Weighted blend of bone matrices + const boneSkinMatrix = boneMat0 + .mul(skinWeight.x) + .add(boneMat1.mul(skinWeight.y)) + .add(boneMat2.mul(skinWeight.z)) + .add(boneMat3.mul(skinWeight.w)) + + // Skin position + const skinned = boneSkinMatrix.mul(vec4(pos, 1.0)) + pos.assign(skinned.xyz) + + // Skin normal (w=0 ignores translation) + normal.assign(boneSkinMatrix.mul(vec4(normal, 0.0)).xyz) + } + + // Apply batching matrix (per-instance world transform) + const batchingMat = getBatchingMatrixFn(batchId) + const batchedPos = batchingMat.mul(vec4(pos, 1.0)).xyz + const batchedNormal = normalize( + batchingMat.mul(vec4(normal, 0.0)).xyz + ) + + // Write varyings for fragment shader + vNormal.assign(batchedNormal) + vWorldPosition.assign(batchedPos) + + return batchedPos + })() + + material.positionNode = positionFn + + // Override setupPosition to skip BatchNode and other standard pipeline steps. + // Our positionNode handles everything: indirect lookup, morphs, skinning, and + // batching matrix application. Without this override, BatchNode would also run + // and potentially interfere with our custom position computation. + material.setupPosition = function () { + positionLocal.assign( + (this as NodeMaterial & { positionNode: any }).positionNode + ) + return positionLocal + } + + material.needsUpdate = true + } + + // Attach setupSkinning to material for consumer access + ;(material as any)._setupSkinning = setupSkinning + + // Compatibility layer for consumer uniform access + ;(material as any).uniforms = { + fadeFactor: uFadeFactor, + mapConfigs: { value: mapConfigs ?? [] }, + uMapSampler: { value: null } + } + + return { + material, + setupSkinning, uniforms: { - uMapSampler: { - value: null - }, - fadeFactor: { - value: 0 - } - }, - vertexShader, - fragmentShader - }) + fadeFactor: uFadeFactor + } + } +} diff --git a/src/shaders/material-characters/vertex.glsl b/src/shaders/material-characters/vertex.glsl deleted file mode 100644 index 643aea6c..00000000 --- a/src/shaders/material-characters/vertex.glsl +++ /dev/null @@ -1,101 +0,0 @@ -#include -#include -#include -#include -#include - -#ifdef USE_MULTI_MAP -varying float vMapIndex; -uniform sampler2D uMapIndex; -#endif - -#ifdef USE_INSTANCED_LIGHT -uniform lowp sampler2D uLightColor; -uniform lowp sampler2D uLightDirection; -uniform lowp sampler2D uPointLightPosition; -uniform lowp sampler2D uPointLightColor; -#endif - -#ifdef USE_LIGHT -uniform vec4 uLightColor; -uniform vec4 uLightDirection; -uniform vec4 uPointLightPosition; -uniform vec4 uPointLightColor; -#endif - -varying vec4 vLightColor; -varying vec4 vLightDirection; -varying vec4 vPointLightPosition; -varying vec4 vPointLightColor; - -uniform sampler2D uMapOffset; -varying vec2 vMapOffset; -varying vec3 vWorldPosition; - -varying vec2 vUv; -varying vec3 vDebug; - -void main() { - vUv = uv; - #include - - // batch Info - float batchId = getIndirectIndex(gl_DrawID); - - // multi map select - #ifdef USE_MULTI_MAP - ivec2 mapIndexCoord = getSampleCoord(uMapIndex, batchId); - vMapIndex = float(texelFetch(uMapIndex, mapIndexCoord, 0).x); - #endif - - // map offset for selecting submaps - ivec2 mapOffsetCoord = getSampleCoord(uMapOffset, batchId); - vMapOffset = texelFetch(uMapOffset, mapOffsetCoord, 0).xy; - - // light direction and color - #ifdef USE_INSTANCED_LIGHT - // light direction - ivec2 lightDirectionCoord = getSampleCoord(uLightDirection, batchId); - vLightDirection = texelFetch(uLightDirection, lightDirectionCoord, 0); - // light color - ivec2 lightColorCoord = getSampleCoord(uLightColor, batchId); - vLightColor = texelFetch(uLightColor, lightColorCoord, 0); - // point light position - ivec2 pointLightPositionCoord = getSampleCoord(uPointLightPosition, batchId); - vPointLightPosition = texelFetch( - uPointLightPosition, - pointLightPositionCoord, - 0 - ); - // point light color - ivec2 pointLightColorCoord = getSampleCoord(uPointLightColor, batchId); - vPointLightColor = texelFetch(uPointLightColor, pointLightColorCoord, 0); - #endif - - #ifdef USE_LIGHT - // light direction - vLightDirection = uLightDirection; - // light color - vLightColor = uLightColor; - // point light position - vPointLightPosition = uPointLightPosition; - // point light color - vPointLightColor = uPointLightColor; - #endif - - #include - - #ifdef USE_SKINNING - #include - #include - #include - #include - #include - #endif - - #include - #include - #include - vWorldPosition = (batchingMatrix * vec4(transformed, 1.0)).xyz; - #include -} diff --git a/src/shaders/material-flow/index.ts b/src/shaders/material-flow/index.ts index c125c4bb..aa848bd5 100644 --- a/src/shaders/material-flow/index.ts +++ b/src/shaders/material-flow/index.ts @@ -32,17 +32,17 @@ export const createFlowMaterial = () => { const samplePrevFn = /* @__PURE__ */ Fn(([uvCoord]: [any]) => { const pixel = uvCoord.mul(FLOW_RESOLUTION) - const p00 = uFeedbackTexture.uv(uvCoord) - const p10 = uFeedbackTexture.uv( + const p00 = uFeedbackTexture.sample(uvCoord) + const p10 = uFeedbackTexture.sample( uvCoord.add(vec2(0.0, -1.0).mul(invRes)) ) - const p01 = uFeedbackTexture.uv( + const p01 = uFeedbackTexture.sample( uvCoord.add(vec2(-1.0, 0.0).mul(invRes)) ) - const p21 = uFeedbackTexture.uv( + const p21 = uFeedbackTexture.sample( uvCoord.add(vec2(1.0, 0.0).mul(invRes)) ) - const p12 = uFeedbackTexture.uv( + const p12 = uFeedbackTexture.sample( uvCoord.add(vec2(0.0, 1.0).mul(invRes)) ) @@ -51,23 +51,23 @@ export const createFlowMaterial = () => { // For each neighbor: replace if neighbor.g < finalSample.g AND neighbor.g < 1.0 // step(ng, fg - eps) = 1 when fg - eps >= ng (i.e. ng < fg approximately) // 1 - step(1.0, ng) = 1 when ng < 1.0 - const r1 = step(p10.g, finalSample.g.sub(0.0001)).mul( - float(1.0).sub(step(float(1.0), p10.g)) + const r1 = float(step(p10.g, finalSample.g.sub(0.0001))).mul( + float(1.0).sub(float(step(float(1.0), p10.g))) ) finalSample.assign(mix(finalSample, p10, r1)) - const r2 = step(p01.g, finalSample.g.sub(0.0001)).mul( - float(1.0).sub(step(float(1.0), p01.g)) + const r2 = float(step(p01.g, finalSample.g.sub(0.0001))).mul( + float(1.0).sub(float(step(float(1.0), p01.g))) ) finalSample.assign(mix(finalSample, p01, r2)) - const r3 = step(p21.g, finalSample.g.sub(0.0001)).mul( - float(1.0).sub(step(float(1.0), p21.g)) + const r3 = float(step(p21.g, finalSample.g.sub(0.0001))).mul( + float(1.0).sub(float(step(float(1.0), p21.g))) ) finalSample.assign(mix(finalSample, p21, r3)) - const r4 = step(p12.g, finalSample.g.sub(0.0001)).mul( - float(1.0).sub(step(float(1.0), p12.g)) + const r4 = float(step(p12.g, finalSample.g.sub(0.0001))).mul( + float(1.0).sub(float(step(float(1.0), p12.g))) ) finalSample.assign(mix(finalSample, p12, r4)) @@ -101,7 +101,7 @@ export const createFlowMaterial = () => { // Mouse circle interaction (branchless) const circle = length(p.sub(uMousePosition)).sub(0.01) const circleNeg = float(1.0).sub(step(float(0.0), circle)) - const mouseIsMoving = step(float(0.001), uMouseMoving) + const mouseIsMoving = float(step(float(0.001), uMouseMoving)) const mouseActive = circleNeg.mul(mouseIsMoving) color.r.assign(mix(color.r, uMouseDepth, mouseActive)) diff --git a/src/shaders/material-global-shader/index.tsx b/src/shaders/material-global-shader/index.tsx index 826e69f5..302bfc64 100644 --- a/src/shaders/material-global-shader/index.tsx +++ b/src/shaders/material-global-shader/index.tsx @@ -167,8 +167,8 @@ export const createGlobalShaderMaterial = ( const normalizedNormal = normalView const viewDir = normalize(positionView.negate()) const oneMinusFadeFactor = float(1.0).sub(uFadeFactor) - const isInspectionMode = step(float(0.001), uInspectingFactor) - const shouldFadeF = uInspectingEnabled.mul( + const isInspectionMode = float(step(float(0.001), uInspectingFactor)) + const shouldFadeF = float(uInspectingEnabled).mul( float(1.0).sub(isInspectionMode) ) @@ -178,13 +178,13 @@ export const createGlobalShaderMaterial = ( if (useMap) { const uvH = vec3(vUv1.x, vUv1.y, float(1.0)) const mapUv = uMapMatrix.mul(uvH).xy.mul(uMapRepeat) - mapSample = uMap.uv(mapUv) + mapSample = uMap.sample(mapUv) } else { mapSample = vec4(1.0, 1.0, 1.0, 1.0) } if (isClouds) { - mapSample = uMap.uv(vec2(vUv1.x.sub(uTime.mul(0.004)), vUv1.y)) + mapSample = uMap.sample(vec2(vUv1.x.sub(uTime.mul(0.004)), vUv1.y)) } // --- Base color --- @@ -194,8 +194,8 @@ export const createGlobalShaderMaterial = ( // --- Lightmap sampling (select between lamp lightmap and regular) --- const lightMapSample = mix( - uLightMap.uv(vUv2).rgb, - uLampLightmap.uv(vUv2).rgb, + uLightMap.sample(vUv2).rgb, + uLampLightmap.sample(vUv2).rgb, uLightLampEnabled ) @@ -213,7 +213,7 @@ export const createGlobalShaderMaterial = ( } if (useEmissiveMap) { - irradiance.mulAssign(uEmissiveMap.uv(vUv1).rgb.mul(ei)) + irradiance.mulAssign(uEmissiveMap.sample(vUv1).rgb.mul(ei)) } } @@ -242,7 +242,7 @@ export const createGlobalShaderMaterial = ( ) .mul(0.495) .add(0.5) - lf.mulAssign(uMatcapTex!.uv(matcapUv).rgb) + lf.mulAssign(uMatcapTex!.sample(matcapUv).rgb) } // --- Lightmap application (skip for VIDEO) --- @@ -258,7 +258,7 @@ export const createGlobalShaderMaterial = ( // --- AO map --- - const ao = uAoMap.uv(vUv2).r.sub(1.0).mul(uAoMapIntensity).add(1.0) + const ao = uAoMap.sample(vUv2).r.sub(1.0).mul(uAoMapIntensity).add(1.0) irradiance.mulAssign( mix(float(1.0), ao, step(float(0.001), uAoMapIntensity)) ) @@ -281,7 +281,7 @@ export const createGlobalShaderMaterial = ( const alphaUv = uAlphaMapTransform .mul(vec3(vUv1.x, vUv1.y, float(1.0))) .xy - opacityResult.mulAssign(uAlphaMap.uv(alphaUv).r) + opacityResult.mulAssign(uAlphaMap.sample(alphaUv).r) } // Discard fully transparent @@ -327,7 +327,7 @@ export const createGlobalShaderMaterial = ( .mul(vec2(0.75, 0.75)) .add(viewDir.xy.mul(vec2(-0.25, 0.25))) .add(vec2(0.125, 0.125)) - const reflexSample = uGlassReflex.uv(glassUv) + const reflexSample = uGlassReflex.sample(glassUv) const reflexActive = step(float(0.001), reflexSample.a) const mixF = float(0.075) const reflexBlend = irradiance diff --git a/src/shaders/material-net/index.ts b/src/shaders/material-net/index.ts index 4168bcb1..dea53151 100644 --- a/src/shaders/material-net/index.ts +++ b/src/shaders/material-net/index.ts @@ -29,14 +29,14 @@ export const createNetMaterial = () => { aUv1.x, float(1.0).sub(uCurrentFrame.div(uTotalFrames)) ) - const offset = tDisplacement.uv(dispUv).xzy + const offset = tDisplacement.sample(dispUv).xzy const pos = positionLocal.toVar() pos.addAssign(offset.mul(uOffsetScale)) return pos })() // Fragment: sample diffuse texture - const texSample = tMap.uv(uv()) + const texSample = tMap.sample(uv()) material.colorNode = texSample.rgb material.opacityNode = texSample.a diff --git a/src/shaders/material-not-found/index.ts b/src/shaders/material-not-found/index.ts index bb25d743..5d11b4ab 100644 --- a/src/shaders/material-not-found/index.ts +++ b/src/shaders/material-not-found/index.ts @@ -30,7 +30,7 @@ const shakeOffset = /* @__PURE__ */ Fn( const shakeX = random(vec2(t03, t03)).mul(2.0).sub(1.0) const shakeY = random(vec2(t02, t02)).mul(2.0).sub(1.0) const t05 = floor(t.mul(0.5)) - const shakeBurst = step(0.58, random(vec2(t05, t05))) + const shakeBurst = float(step(0.58, random(vec2(t05, t05)))) return vec2(shakeX, shakeY).mul(i).mul(shakeBurst) } ) @@ -56,9 +56,9 @@ export const createNotFoundMaterial = () => { const shiftedUv = uv2.mul(-1.0).add(0.5) // Texture sampling with color bleeding - const baseColor = tDiffuse.uv(shiftedUv) - const colorBleedUp = tDiffuse.uv(shiftedUv.add(vec2(0.0, 0.001))) - const colorBleedDown = tDiffuse.uv(shiftedUv.sub(vec2(0.0, 0.001))) + const baseColor = tDiffuse.sample(shiftedUv) + const colorBleedUp = tDiffuse.sample(shiftedUv.add(vec2(0.0, 0.001))) + const colorBleedDown = tDiffuse.sample(shiftedUv.sub(vec2(0.0, 0.001))) const colorWithBleed = baseColor.add( colorBleedUp.add(colorBleedDown).mul(0.5) ) diff --git a/src/shaders/material-postprocessing/index.ts b/src/shaders/material-postprocessing/index.ts index 3492e375..003aa37d 100644 --- a/src/shaders/material-postprocessing/index.ts +++ b/src/shaders/material-postprocessing/index.ts @@ -7,7 +7,7 @@ import { vec3, vec4, uniform, - uv, + screenUV, texture, screenCoordinate, sin, @@ -34,7 +34,9 @@ const GOLDEN_ANGLE = 2.399963229728653 const PI_2 = 6.28318530718 export const createPostProcessingMaterial = () => { - const uMainTexture = texture(new Texture()) + const mainTexPlaceholder = new Texture() + mainTexPlaceholder.isRenderTargetTexture = true + const uMainTexture = texture(mainTexPlaceholder) const uDepthTexture = texture(new Texture()) const uResolution = uniform(new Vector2(1, 1)) const uPixelRatio = uniform(1) @@ -130,7 +132,7 @@ export const createPostProcessingMaterial = () => { const LUMINANCE_FACTORS = vec3(0.2126, 0.7152, 0.0722) const postProcessResult = Fn(() => { - const vUv = uv() + const vUv = screenUV // Checker pattern using screen coordinates const checkerSize = float(2.0).mul(uPixelRatio) @@ -147,25 +149,25 @@ export const createPostProcessingMaterial = () => { const pixelatedUvEighth = floor(vUv.mul(eighthResolution)).mul(8.0).div(uResolution) // Main texture sample + tonemap - const baseColorSample = uMainTexture.uv(vUv) + const baseColorSample = uMainTexture.sample(vUv) const color = tonemapFn(baseColorSample.rgb).toVar() // Alpha / reveal logic (branchless) - const opacityOk = step(float(0.001), uOpacity) // 1 when opacity >= 0.001 + const opacityOk = float(step(float(0.001), uOpacity)) // 1 when opacity >= 0.001 const reveal = clamp(float(1.0).sub(uOpacity), 0.0, 1.0) const revealPow = reveal.mul(reveal).mul(reveal).mul(reveal) - const basePixelatedSample = uMainTexture.uv(pixelatedUvEighth) + const basePixelatedSample = uMainTexture.sample(pixelatedUvEighth) const baseBrightness = dot(tonemapFn(basePixelatedSample.rgb), LUMINANCE_FACTORS) // revealHides = 1 when reveal is active AND brightness < reveal threshold - const revealHides = step(float(0.001), revealPow).mul( - float(1.0).sub(step(revealPow, baseBrightness)) + const revealHides = float(step(float(0.001), revealPow)).mul( + float(1.0).sub(float(step(revealPow, baseBrightness))) ) const alpha = opacityOk.mul(float(1.0).sub(revealHides)) // Bloom (Vogel disk sampling) - const bloomActive = step(float(0.001), uBloomStrength) - .mul(step(float(0.5), checkerPattern)) - .mul(step(float(0.5), uActiveBloom)) + const bloomActive = float(step(float(0.001), uBloomStrength)) + .mul(float(step(float(0.5), checkerPattern))) + .mul(float(step(float(0.5), uActiveBloom))) const bloom = vec3(0.0, 0.0, 0.0).toVar() const totalWeight = float(0.0).toVar() @@ -188,10 +190,10 @@ export const createPostProcessingMaterial = () => { ) const sampleColor = uMainTexture - .uv(pixelatedUv.add(sampleOffset).add(invResolution)) + .sample(pixelatedUv.add(sampleOffset).add(invResolution)) .rgb const brightness = dot(sampleColor, LUMINANCE_FACTORS) - const shouldAdd = step(uBloomThreshold, brightness) + const shouldAdd = float(step(uBloomThreshold, brightness)) totalWeight.addAssign(weight) bloom.addAssign(sampleColor.mul(weight).mul(shouldAdd)) diff --git a/src/shaders/material-screen/index.ts b/src/shaders/material-screen/index.ts index 538c4d93..c36b3863 100644 --- a/src/shaders/material-screen/index.ts +++ b/src/shaders/material-screen/index.ts @@ -119,17 +119,17 @@ export const createScreenMaterial = () => { const relSquare = abs(centeredUv.sub(vec2(-0.01, -0.4))) const inSquare = float(1.0) - .sub(step(0.25, relSquare.x)) - .mul(float(1.0).sub(step(0.05, relSquare.y))) + .sub(float(step(0.25, relSquare.x))) + .mul(float(1.0).sub(float(step(0.05, relSquare.y)))) const relCenter = abs(centeredUv.sub(vec2(-0.35, 0.5))) const inCenterSquare = float(1.0) - .sub(step(0.2, relCenter.x)) - .mul(float(1.0).sub(step(0.2, relCenter.y))) + .sub(float(step(0.2, relCenter.x))) + .mul(float(1.0).sub(float(step(0.2, relCenter.y)))) const notInCenter = float(1.0).sub(inCenterSquare) const notInSquare = float(1.0).sub(inSquare) - const gameRunning = step(0.5, uIsGameRunning) + const gameRunning = float(step(0.5, uIsGameRunning)) // shouldPixelate = uFlip AND !center AND (gameRunning OR !square) const shouldPixelate = uFlip @@ -149,15 +149,15 @@ export const createScreenMaterial = () => { remappedUv.x.addAssign(expApprox.mul(SCAN_DISTORTION)) // Texture sampling with boundary check - const validX = step(0.0, remappedUv.x).mul( - step(0.0, float(1.0).sub(remappedUv.x)) + const validX = float(step(0.0, remappedUv.x)).mul( + float(step(0.0, float(1.0).sub(remappedUv.x))) ) - const validY = step(0.0, remappedUv.y).mul( - step(0.0, float(1.0).sub(remappedUv.y)) + const validY = float(step(0.0, remappedUv.y)).mul( + float(step(0.0, float(1.0).sub(remappedUv.y))) ) const validUV = validX.mul(validY) - const textureColor = mapTex.uv(remappedUv).rgb.mul(validUV).toVar() + const textureColor = mapTex.sample(remappedUv).rgb.mul(validUV).toVar() // Color grading: luma, tint, brightness const luma = dot(textureColor, vec3(0.8, 0.1, 0.1)) @@ -167,7 +167,7 @@ export const createScreenMaterial = () => { // Line reveal const currentLine = floor(vUv.y.div(LINE_HEIGHT)) const revealLine = floor(uRevealProgress.div(LINE_HEIGHT)) - const textureVisibility = step(currentLine, revealLine).mul(0.8) + const textureVisibility = float(step(currentLine, revealLine)).mul(0.8) const color = textureColor.mul(textureVisibility).toVar() @@ -190,7 +190,7 @@ export const createScreenMaterial = () => { color.assign(mix(color.add(orangeTint.mul(0.1)), color, isNotBlack)) // Scanlines - const scanline = step(0.5, fract(vPos.y.mul(SCANLINE_COUNT))) + const scanline = float(step(0.5, fract(vPos.y.mul(SCANLINE_COUNT)))) const scanlineFactor = mix( float(1.0), float(0.7), diff --git a/src/shaders/material-solid-reveal/index.ts b/src/shaders/material-solid-reveal/index.ts index 3de9665c..38059df6 100644 --- a/src/shaders/material-solid-reveal/index.ts +++ b/src/shaders/material-solid-reveal/index.ts @@ -83,13 +83,13 @@ export const createSolidRevealMaterial = () => { colorBump.assign(pow(colorBump, float(2.0))) // Branchless reveal condition colorBump.mulAssign( - step(pow(noiseSmall.mul(0.5).add(0.5), float(2.0)), uReveal) + float(step(pow(noiseSmall.mul(0.5).add(0.5), float(2.0)), uReveal)) ) colorBump.mulAssign(0.4) // Project voxel center to screen UV for flow texture sampling const screenUv = worldToUvFn(voxelCenter) - const flowColor = uFlowTexture.uv(screenUv) + const flowColor = uFlowTexture.sample(screenUv) // Flow SDF computation const distToCamera = distance(cameraPosition, voxelCenter) diff --git a/src/shaders/material-steam/index.ts b/src/shaders/material-steam/index.ts index d0005672..c2aea1ae 100644 --- a/src/shaders/material-steam/index.ts +++ b/src/shaders/material-steam/index.ts @@ -34,7 +34,7 @@ export const createSteamMaterial = () => { // Noise-based offset const noiseUv1 = vec2(float(0.25), uTime.mul(0.005)) - const noiseVal = uNoiseTex.uv(noiseUv1).r + const noiseVal = uNoiseTex.sample(noiseUv1).r const offsetAmount = noiseVal.mul(pow(vUv.y, float(1.2))).mul(0.035) pos.x.addAssign(offsetAmount) @@ -42,7 +42,7 @@ export const createSteamMaterial = () => { // Noise-based twist const noiseUv2 = vec2(float(0.5), vUv.y.mul(0.2).sub(uTime.mul(0.005))) - const twist = uNoiseTex.uv(noiseUv2).r + const twist = uNoiseTex.sample(noiseUv2).r const angle = twist.mul(8.0) const s = sin(angle) const c = cos(angle) @@ -64,7 +64,7 @@ export const createSteamMaterial = () => { // Steam from noise const steamUv = vec2(vUv.x.mul(0.5), vUv.y.mul(0.3).sub(uTime.mul(0.015))) - const steam = smoothstep(float(0.45), float(1.0), uNoiseTex.uv(steamUv).r) + const steam = smoothstep(float(0.45), float(1.0), uNoiseTex.sample(steamUv).r) // Edge fades const edgeFadeX = smoothstep(float(0.0), float(0.15), vUv.x) diff --git a/src/shaders/utils/basic-light.glsl b/src/shaders/utils/basic-light.glsl deleted file mode 100644 index c6044954..00000000 --- a/src/shaders/utils/basic-light.glsl +++ /dev/null @@ -1,14 +0,0 @@ -#pragma glslify: valueRemap = require('../utils/value-remap.glsl') - -float basicLight(vec3 normal, vec3 lightDir, float intensity) { - float lightFactor = dot(lightDir, normalize(normal)); - lightFactor = valueRemap(lightFactor, 0.2, 1.0, 0.1, 1.0); - lightFactor = clamp(lightFactor, 0.0, 1.0); - lightFactor = pow(lightFactor, 2.0); - lightFactor *= intensity; - lightFactor += 1.0; - - return lightFactor; -} - -#pragma glslify: export(basicLight) diff --git a/src/shaders/utils/value-remap.glsl b/src/shaders/utils/value-remap.glsl deleted file mode 100644 index c600d70a..00000000 --- a/src/shaders/utils/value-remap.glsl +++ /dev/null @@ -1,30 +0,0 @@ -float valueRemap(float value, float min, float max) { - return (value - min) / (max - min); -} - -float valueRemap( - float value, - float min, - float max, - float newMin, - float newMax -) { - return (value - min) / (max - min) * (newMax - newMin) + newMin; -} - -vec2 valueRemap(vec2 value, vec2 min, vec2 max, vec2 newMin, vec2 newMax) { - return vec2( - valueRemap(value.x, min.x, max.x, newMin.x, newMax.x), - valueRemap(value.y, min.y, max.y, newMin.y, newMax.y) - ); -} - -vec3 valueRemap(vec3 value, vec3 min, vec3 max, vec3 newMin, vec3 newMax) { - return vec3( - valueRemap(value.x, min.x, max.x, newMin.x, newMax.x), - valueRemap(value.y, min.y, max.y, newMin.y, newMax.y), - valueRemap(value.z, min.z, max.z, newMin.z, newMax.z) - ); -} - -#pragma glslify: export(valueRemap) diff --git a/src/workers/loading-worker.tsx b/src/workers/loading-worker.tsx index 597697ca..e6756f89 100644 --- a/src/workers/loading-worker.tsx +++ b/src/workers/loading-worker.tsx @@ -1,5 +1,6 @@ import { render } from "@react-three/offscreen" import { Vector3 } from "three" +import { WebGPURenderer } from "three/webgpu" import LoadingScene from "@/components/loading/loading-scene" @@ -20,6 +21,33 @@ self.addEventListener("message", (e: LoadingWorkerMessageEvent) => { if (type === "initialize" && modelUrl) { render() + + // Intercept the onmessage handler set by render() to override + // the renderer with WebGPURenderer (needed for TSL/NodeMaterial) + const originalHandler = self.onmessage + self.onmessage = (event: MessageEvent) => { + if (event.data.type === "init") { + event.data.payload.props.gl = async (defaultProps: any) => { + const renderer = new WebGPURenderer({ + canvas: defaultProps.canvas, + antialias: true, + alpha: true + }) + await renderer.init() + return renderer + } + } + + // Block "props" messages to prevent a race condition with async WebGPU init. + // The OffscreenCanvas component sends a "props" message on mount that arrives + // while the async "init" is still in progress, causing R3F to create a second + // (WebGL) renderer. This prevents the WebGPU renderer from being properly sized + // since R3F's resize subscription only fires setSize when size/dpr changes. + // The "init" message already includes all necessary props (frameloop, etc.). + if (event.data.type === "props") return + + originalHandler?.call(self, event) + } } }) From b2215e0791d3e01ec6f09d1023388201740a32ff Mon Sep 17 00:00:00 2001 From: ragojose Date: Thu, 5 Feb 2026 12:05:30 -0300 Subject: [PATCH 18/21] perf: optimize for 120fps WebGPU rendering - Shared uniform nodes: single write propagates to all materials (O(1) vs O(N)) - Shared sparkle material: eliminates 9 redundant shader compilations - Remove Zustand material store (no longer needed with shared uniforms) - GPU billboard for RoutingPlus (moved from CPU to TSL) - Replace useThree size/pointer subscriptions with imperative reads in useFrame to eliminate React re-renders on resize/mouse move - Remove double rendering feedback loop in loading scene - Throttle raycaster to every 4th frame in loading scene - Reduce flow FBO from 1024x1024 to 512x512 - Read actualCamera imperatively via getState() instead of subscribing - Guard updateProjectionMatrix with FOV change detection - Consolidate 4 useFrame callbacks into 2 in loading scene - Replace React setState with imperative Three.js scene management for outdoor cars (zero re-renders from frame loop) - Pre-allocate Vector3/Color/Quaternion objects to eliminate per-frame GC pressure in camera, inspectable, lamp, christmas-tree, pets, contact-scene, basketball components - Use TSL viewportSize instead of CPU-side uResolution uniform - Fix useThree() calls without selectors (full-store subscriptions) - Reduce bloom samples from 24 to 16 - Add WebGPU-compatible FPS monitor (Stats panel gated by ?debug) - Delete unused use-gpu.ts hook Co-Authored-By: Claude Opus 4.5 --- src/components/arcade-game/entities/grid.tsx | 2 +- src/components/arcade-screen/index.tsx | 2 +- src/components/basketball/hoop-minigame.tsx | 13 +- src/components/camera/camera-controller.tsx | 2 +- src/components/camera/camera-hooks.tsx | 27 ++- src/components/christmas-tree/client.tsx | 15 +- src/components/contact/contact-scene.tsx | 3 +- .../inspectables/inspectable-dragger.tsx | 2 +- src/components/inspectables/inspectable.tsx | 17 +- src/components/lamp/index.tsx | 8 +- .../loading/loading-scene/index.tsx | 146 ++++++++-------- src/components/map/use-frame-loop.ts | 12 +- src/components/outdoor-cars/index.tsx | 29 ++-- src/components/pets/index.tsx | 5 +- .../postprocessing/post-processing.tsx | 22 ++- src/components/postprocessing/renderer.tsx | 18 +- .../routing-element/routing-element.tsx | 15 +- .../routing-element/routing-plus.tsx | 48 ++++-- src/components/scene/index.tsx | 5 + src/components/scene/stats-monitor.tsx | 26 +++ src/components/sparkles/index.tsx | 159 +++++++++--------- src/hooks/use-gpu.ts | 12 -- src/hooks/use-handle-contact.ts | 5 +- src/shaders/material-flow/index.ts | 2 +- src/shaders/material-global-shader/index.tsx | 77 ++++----- src/shaders/material-postprocessing/index.ts | 2 +- 26 files changed, 355 insertions(+), 319 deletions(-) create mode 100644 src/components/scene/stats-monitor.tsx delete mode 100644 src/hooks/use-gpu.ts diff --git a/src/components/arcade-game/entities/grid.tsx b/src/components/arcade-game/entities/grid.tsx index 7e5e539b..eee5da0a 100644 --- a/src/components/arcade-game/entities/grid.tsx +++ b/src/components/arcade-game/entities/grid.tsx @@ -71,7 +71,7 @@ export const Grid = ({ divisions, caps = false }: GridProps) => { - const { camera } = useThree() + const camera = useThree((state) => state.camera) const lineRef = useRef(null) const [divisionsX, divisionsY] = useMemo(() => { diff --git a/src/components/arcade-screen/index.tsx b/src/components/arcade-screen/index.tsx index 4f9addd8..f5ae48c9 100644 --- a/src/components/arcade-screen/index.tsx +++ b/src/components/arcade-screen/index.tsx @@ -44,7 +44,7 @@ const ScreenUI = dynamic( ) export const ArcadeScreen = () => { - const { scene } = useThree() + const scene = useThree((state) => state.scene) const pathname = usePathname() const currentScene = useCurrentScene() const isLabRoute = pathname === "/lab" diff --git a/src/components/basketball/hoop-minigame.tsx b/src/components/basketball/hoop-minigame.tsx index e4dc0c8f..eb9f0af5 100644 --- a/src/components/basketball/hoop-minigame.tsx +++ b/src/components/basketball/hoop-minigame.tsx @@ -76,10 +76,12 @@ const HoopMinigameInner = () => { dragStartPos: new Vector3(), currentBallPos: new Vector3(), targetPos: new Vector3(), - currentRot: new Vector3() + currentRot: new Vector3(), + resetTarget: new Vector3(), + resetLerp: new Vector3() }).current - const { camera } = useThree() + const camera = useThree((state) => state.camera) const bounceCount = useRef(0) const resetProgress = useRef(0) const startResetPos = useRef(new Vector3()) @@ -520,16 +522,15 @@ const HoopMinigameInner = () => { const progress = MathUtils.clamp(resetProgress.current, 0, 1) const easedProgress = easeInOutCubic(progress) - // Create a completely new Vector3 for the new position - const targetPosition = new Vector3( + positionVectors.resetTarget.set( initialPosition.x, initialPosition.y, initialPosition.z ) - const newPosition = new Vector3().lerpVectors( + const newPosition = positionVectors.resetLerp.lerpVectors( startResetPos.current, - targetPosition, + positionVectors.resetTarget, easedProgress ) diff --git a/src/components/camera/camera-controller.tsx b/src/components/camera/camera-controller.tsx index 0d455ad5..18c208bc 100644 --- a/src/components/camera/camera-controller.tsx +++ b/src/components/camera/camera-controller.tsx @@ -10,7 +10,7 @@ import { WasdControls } from "./wasd-controls" export const CameraController = () => { const [isFlyMode, setIsFlyMode] = useState(true) - const { camera } = useThree() + const camera = useThree((state) => state.camera) const setMainCamera = useNavigationStore((state) => state.setMainCamera) useControls("camera", { diff --git a/src/components/camera/camera-hooks.tsx b/src/components/camera/camera-hooks.tsx index 793ca9bb..f23ccb11 100644 --- a/src/components/camera/camera-hooks.tsx +++ b/src/components/camera/camera-hooks.tsx @@ -179,11 +179,14 @@ export const useCameraMovement = ( const newDelta = useMemo(() => new THREE.Vector3(), []) const newLookAtDelta = useMemo(() => new THREE.Vector3(), []) + const finalPos = useMemo(() => new THREE.Vector3(), []) + const finalLookAt = useMemo(() => new THREE.Vector3(), []) const progress = useRef(1) const isTransitioning = useRef(false) const prevCameraConfig = useRef(cameraConfig) const firstRender = useRef(true) + const prevFov = useRef(cameraConfig?.fov ?? 75) const loadingCanvasWorker = useAppLoadingStore((state) => state.worker) @@ -265,10 +268,10 @@ export const useCameraMovement = ( } if (!disableCameraTransition && isDesktop) { - targetPosition.y += - (targetY - initialY) * Math.min(1, window.scrollY / window.innerHeight) - targetLookAt.y += + const scrollFactor = (targetY - initialY) * Math.min(1, window.scrollY / window.innerHeight) + targetPosition.y += scrollFactor + targetLookAt.y += scrollFactor } if (disableCameraTransition || firstRender.current) { @@ -276,8 +279,10 @@ export const useCameraMovement = ( currentPos.copy(targetPosition) currentTarget.copy(targetLookAt) currentFov.current = targetFov.current - isTransitioning.current = false - setIsCameraTransitioning(false) + if (isTransitioning.current) { + isTransitioning.current = false + setIsCameraTransitioning(false) + } if (firstRender.current) { firstRender.current = false @@ -303,13 +308,17 @@ export const useCameraMovement = ( } if (cameraRef.current) { - const finalPos = currentPos.clone().add(panTargetDelta) - const finalLookAt = currentTarget.clone().add(panLookAtDelta) + finalPos.copy(currentPos).add(panTargetDelta) + finalLookAt.copy(currentTarget).add(panLookAtDelta) cameraRef.current.position.copy(finalPos) cameraRef.current.lookAt(finalLookAt) - cameraRef.current.fov = currentFov.current - cameraRef.current.updateProjectionMatrix() + + if (currentFov.current !== prevFov.current) { + cameraRef.current.fov = currentFov.current + cameraRef.current.updateProjectionMatrix() + prevFov.current = currentFov.current + } if (loadingCanvasWorker) { loadingCanvasWorker.postMessage({ diff --git a/src/components/christmas-tree/client.tsx b/src/components/christmas-tree/client.tsx index 31de5969..970e5bea 100644 --- a/src/components/christmas-tree/client.tsx +++ b/src/components/christmas-tree/client.tsx @@ -32,6 +32,10 @@ const INTENSITIES = { star: 16 } +// Pre-allocated for per-frame star color animation (avoids new Color() each frame) +const _animatedColor = new Color() +const _hsl = { h: 0, s: 0, l: 0 } + export const ClientChristmasTree = () => { const { specialEvents } = useAssets() const { handleMute, music } = useSiteAudio() @@ -192,19 +196,16 @@ export const ClientChristmasTree = () => { } }) - const baseColor = COLORS.star - const hsl = { h: 0, s: 0, l: 0 } - baseColor.getHSL(hsl) - const animatedHue = (hsl.h + 0.1 * Math.sin(time * 1.2)) % 1 - const animatedColor = new Color() - animatedColor.setHSL(animatedHue, hsl.s, hsl.l) + COLORS.star.getHSL(_hsl) + const animatedHue = (_hsl.h + 0.1 * Math.sin(time * 1.2)) % 1 + _animatedColor.setHSL(animatedHue, _hsl.s, _hsl.l) const starOscillation = Math.abs(Math.sin(time * 2.5)) const animatedStarIntensity = INTENSITIES.star + INTENSITIES.star * starOscillation ballMeshRefs.current.star.forEach((mesh, i) => { if (mesh.material && "uniforms" in mesh.material) { const material = mesh.material as ShaderMaterial - material.uniforms.emissive.value = animatedColor + material.uniforms.emissive.value = _animatedColor material.uniforms.emissiveIntensity.value = animatedStarIntensity } }) diff --git a/src/components/contact/contact-scene.tsx b/src/components/contact/contact-scene.tsx index 17cd3f43..5a2552e9 100644 --- a/src/components/contact/contact-scene.tsx +++ b/src/components/contact/contact-scene.tsx @@ -14,6 +14,7 @@ import { import { ANIMATION_TYPES } from "./contact.interface" const IDLE_ANIMATIONS = [ANIMATION_TYPES.RUEDITA, ANIMATION_TYPES.ANTENA] +const SCREEN_OFFSET = new Vector3(-0.0342, 0.043, 0) export const ContactScene = ({ modelUrl }: { modelUrl: string }) => { const { scene, animations, nodes } = useGLTF(modelUrl) @@ -311,7 +312,7 @@ export const ContactScene = ({ modelUrl }: { modelUrl: string }) => { const screenbone = nodes.Obj as Bone if (screenbone && debugMeshRef.current) { screenbone.getWorldPosition(tmp) - tmp.add(new Vector3(-0.0342, 0.043, 0)) + tmp.add(SCREEN_OFFSET) debugMeshRef.current.position.copy(tmp) } diff --git a/src/components/inspectables/inspectable-dragger.tsx b/src/components/inspectables/inspectable-dragger.tsx index eed97604..bccde321 100644 --- a/src/components/inspectables/inspectable-dragger.tsx +++ b/src/components/inspectables/inspectable-dragger.tsx @@ -50,7 +50,7 @@ export const InspectableDragger = ({ const ref = React.useRef(null) - const { size } = useThree() + const size = useThree((state) => state.size) const rPolar = React.useMemo( () => [rotation[0] + polar[0], rotation[0] + polar[1]], diff --git a/src/components/inspectables/inspectable.tsx b/src/components/inspectables/inspectable.tsx index 5f2c5b60..1ff05b2d 100644 --- a/src/components/inspectables/inspectable.tsx +++ b/src/components/inspectables/inspectable.tsx @@ -220,28 +220,27 @@ export const Inspectable = memo(function InspectableInner({ return { targetQuaternion: new Quaternion(), lookAtMatrix: new Matrix4(), - upVector: new Vector3(0, 1, 0) + upVector: new Vector3(0, 1, 0), + cameraPosition: new Vector3(), + tempQuaternion: new Quaternion(), + direction: new Vector3() } }, []) useFrameCallback(() => { const camConfig = camConfigRef.current if (!ref.current || !camConfig) return - const { targetQuaternion, lookAtMatrix, upVector } = vRef + const { targetQuaternion, lookAtMatrix, upVector, cameraPosition, tempQuaternion } = vRef if (selected === id) { - const cameraPosition = new Vector3(...camConfig.position) + cameraPosition.set(camConfig.position[0], camConfig.position[1], camConfig.position[2]) cameraPosition.add(perpendicularMoved.current) cameraPosition.y += yOffset lookAtMatrix.lookAt(cameraPosition, ref.current.position, upVector) targetQuaternion.setFromRotationMatrix(lookAtMatrix) - const q = new Quaternion() - q.setFromAxisAngle(vRef.upVector, -Math.PI / 2 + xRotationOffset) - targetQuaternion.multiply(q) - - const direction = new Vector3() - direction.setFromMatrixColumn(lookAtMatrix, 2).negate() + tempQuaternion.setFromAxisAngle(upVector, -Math.PI / 2 + xRotationOffset) + targetQuaternion.multiply(tempQuaternion) } else { targetQuaternion.identity() } diff --git a/src/components/lamp/index.tsx b/src/components/lamp/index.tsx index c2df534c..244f604a 100644 --- a/src/components/lamp/index.tsx +++ b/src/components/lamp/index.tsx @@ -125,8 +125,10 @@ export const Lamp = memo(function LampInner() { } }, [lightmap, lampTargets]) + const dragDelta = useMemo(() => new THREE.Vector3(), []) + const tensionVec = useMemo(() => new THREE.Vector3(), []) const tension = (point1: THREE.Vector3, point2: THREE.Vector3) => - new THREE.Vector3().copy(point1).sub(point2).length() + tensionVec.copy(point1).sub(point2).length() useEffect(() => { if (selected) { @@ -153,8 +155,8 @@ export const Lamp = memo(function LampInner() { dir.copy(vec).sub(state.camera.position).normalize() vec.add(dir.multiplyScalar(state.camera.position.length() * 0.079)) - const delta = new THREE.Vector3().copy(vec).sub(j3.current.translation()) - if (delta.length() > 0.1) { + dragDelta.copy(vec).sub(j3.current.translation()) + if (dragDelta.length() > 0.1) { drag(false) return } diff --git a/src/components/loading/loading-scene/index.tsx b/src/components/loading/loading-scene/index.tsx index 5d42f8d5..b143e7d0 100644 --- a/src/components/loading/loading-scene/index.tsx +++ b/src/components/loading/loading-scene/index.tsx @@ -27,6 +27,8 @@ import { easeInOutCirc } from "@/utils/math/easings" import { clamp } from "@/utils/math/interpolation" import type { LoadingWorkerMessageEvent } from "@/workers/loading-worker" +const BLACK = new Color(0, 0, 0) + interface LoadingWorkerStore { isAppLoaded: boolean progress: number @@ -85,7 +87,6 @@ interface GLTFNodes extends GLTF { } function LoadingScene({ modelUrl }: { modelUrl: string }) { - const { actualCamera } = useLoadingWorkerStore() const { nodes } = useGLTF(modelUrl!) as any as GLTFNodes const solidParent = nodes.SM_Solid @@ -100,14 +101,8 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { const solidMaterial = solid.material as ShaderMaterial - useFrame(({ clock }) => { - const elapsedTime = clock.getElapsedTime() - const material = solid.material as any - material.uniforms.uTime.value = elapsedTime - }) - const flowDoubleFbo = useMemo(() => { - const fbo = doubleFbo(1024, 1024, { + const fbo = doubleFbo(512, 512, { magFilter: NearestFilter, minFilter: NearestFilter, type: HalfFloatType @@ -125,30 +120,9 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { return new Group() }, []) - const isAppLoaded = useLoadingWorkerStore((s) => s.isAppLoaded) - const uScreenReveal = useRef(0) - const sentMessage = useRef(false) - // Fade out canvas - useFrame((_, delta) => { - if (!isAppLoaded) return - - if (uScreenReveal.current < 1) { - uScreenReveal.current += delta - ;(solid.material as any).uniforms.uScreenReveal.value = - uScreenReveal.current - } else { - // remove canvas - // send message to stop - if (!sentMessage.current) { - self.postMessage({ type: "loading-transition-complete" }) - sentMessage.current = true - } - } - }) - useEffect(() => { self.postMessage({ type: "offscreen-canvas-loaded" }) }, [solid]) @@ -176,21 +150,41 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { const gl = useThree((state) => state.gl) - gl.setClearAlpha(0) - gl.setClearColor(new Color(0, 0, 0), 0) + useEffect(() => { + gl.setClearAlpha(0) + gl.setClearColor(BLACK, 0) + }, [gl]) const renderCount = useRef(0) - /** - * Another approach, camera just appears there - * */ const updated = useRef(false) - useFrame(({ camera: C, scene, clock }) => { + const prevFov = useRef(0) + const prevNear = useRef(0) + const prevFar = useRef(0) + + useFrame(({ camera: C, scene, clock }, delta) => { const elapsedTime = clock.getElapsedTime() renderCount.current++ const time = elapsedTime - let r = Math.min(time * 1, 1) + // Update uTime (previously a separate useFrame) + ;(solid.material as any).uniforms.uTime.value = elapsedTime + + // Screen reveal fade (previously a separate useFrame) + const appLoaded = useLoadingWorkerStore.getState().isAppLoaded + if (appLoaded) { + if (uScreenReveal.current < 1) { + uScreenReveal.current += delta + ;(solid.material as any).uniforms.uScreenReveal.value = + uScreenReveal.current + } else if (!sentMessage.current) { + self.postMessage({ type: "loading-transition-complete" }) + sentMessage.current = true + } + } + + // Line reveal animation + let r = Math.min(time, 1) r = clamp(r, 0, 1) r = easeInOutCirc(0, 1, r) ;(lines.material as any).uniforms.uReveal.value = r @@ -199,7 +193,6 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { lines.visible = Math.sin(time * 50) > 0 ;(lines.material as any).uniforms.uOpacity.value = 0.1 } else { - // remove lines when app is loaded lines.visible = true ;(lines.material as any).uniforms.uOpacity.value = 0.3 } @@ -218,6 +211,8 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { r2 = easeInOutCirc(0, 1, r2) ;(solid.material as any).uniforms.uReveal.value = r2 + // Camera sync — read imperatively to avoid React re-renders + const actualCamera = useLoadingWorkerStore.getState().actualCamera const camera = C as PerspectiveCameraType if (actualCamera) { updated.current = true @@ -232,17 +227,25 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { actualCamera.target.z ) camera.lookAt(target) - camera.fov = actualCamera.fov - camera.updateProjectionMatrix() + + // Only update projection matrix when FOV changes + if (actualCamera.fov !== prevFov.current) { + camera.fov = actualCamera.fov + camera.updateProjectionMatrix() + prevFov.current = actualCamera.fov + } } if (updated.current) { - // TODO re-enable this - solidMaterial.uniforms.uNear.value = camera.near - solidMaterial.uniforms.uFar.value = camera.far + // Only update near/far when changed + if (camera.near !== prevNear.current || camera.far !== prevFar.current) { + solidMaterial.uniforms.uNear.value = camera.near + solidMaterial.uniforms.uFar.value = camera.far + prevNear.current = camera.near + prevFar.current = camera.far + } gl.setRenderTarget(null) gl.render(scene, camera) } - return }, 1) const flowCamera = useMemo(() => { @@ -252,8 +255,6 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { return oc }, []) - // const [handlePointerMove, lerpMouseFloor, vRefsFloor] = useLerpMouse() - const vRefsFloor = useMemo( () => ({ uv: new Vector2(0, 0), @@ -264,28 +265,35 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { [] ) - const raycaster = new Raycaster() - const camera = useThree((s) => s.camera) - const pointer = useThree((s) => s.pointer) + const raycaster = useMemo(() => new Raycaster(), []) + + const lastScreenSize = useRef({ w: 0, h: 0 }) + const raycastFrame = useRef(0) + + useFrame(({ gl, camera, pointer, size }, delta) => { + // Update screen size uniform only when changed + if (size.width !== lastScreenSize.current.w || size.height !== lastScreenSize.current.h) { + lastScreenSize.current.w = size.width + lastScreenSize.current.h = size.height + solidMaterial.uniforms.uScreenSize.value.set(size.width, size.height) + } - const renderFlow = (gl: any, delta: number) => { gl.setRenderTarget(null) vRefsFloor.smoothPointer.lerp(pointer, Math.min(delta * 10, 1)) - const distance = vRefsFloor.smoothPointer.distanceTo( + const dist = vRefsFloor.smoothPointer.distanceTo( vRefsFloor.prevSmoothPointer ) vRefsFloor.prevSmoothPointer.copy(vRefsFloor.smoothPointer) - raycaster.setFromCamera(pointer, camera) - const intersects = raycaster.intersectObject(solid) - - if (intersects.length) { - const sortedInt = intersects.sort((a, b) => a.distance - b.distance) - - const int = sortedInt[0] - const distance = int.distance - vRefsFloor.depth = distance + // Throttle raycaster to every 4th frame — depth changes slowly + raycastFrame.current++ + if (raycastFrame.current % 4 === 0) { + raycaster.setFromCamera(pointer, camera) + const intersects = raycaster.intersectObject(solid) + if (intersects.length) { + vRefsFloor.depth = intersects[0].distance + } } vRefsFloor.uv.copy(vRefsFloor.smoothPointer) @@ -303,27 +311,9 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { vRefsFloor.uv.x, vRefsFloor.uv.y ) - flowMaterial.uniforms.uMouseMoving.value = distance > 0.001 ? 1.0 : 0.0 - } - - useFrame(({ gl }, delta) => { - const fps = 1 / delta - - const shouldDoubleRender = fps < 100 - - const d = shouldDoubleRender ? delta / 2 : delta - - renderFlow(gl, d) - if (shouldDoubleRender) { - renderFlow(gl, d) - } + flowMaterial.uniforms.uMouseMoving.value = dist > 0.001 ? 1.0 : 0.0 }, 2) - const width = useThree((s) => s.size.width) - const height = useThree((s) => s.size.height) - - solidMaterial.uniforms.uScreenSize.value.set(width, height) - return ( <> diff --git a/src/components/map/use-frame-loop.ts b/src/components/map/use-frame-loop.ts index 3265c647..507cdee3 100644 --- a/src/components/map/use-frame-loop.ts +++ b/src/components/map/use-frame-loop.ts @@ -1,19 +1,15 @@ import { useFadeAnimation } from "@/components/inspectables/use-fade-animation" import { useMesh } from "@/hooks/use-mesh" import { useFrameCallback } from "@/hooks/use-pausable-time" -import { useCustomShaderMaterial } from "@/shaders/material-global-shader" +import { globalUniforms } from "@/shaders/material-global-shader" export const useFrameLoop = () => { - const shaderMaterial = useCustomShaderMaterial((store) => store.materialsRef) const { fadeFactor, inspectingEnabled } = useFadeAnimation() useFrameCallback((_, delta) => { - Object.values(shaderMaterial).forEach((material) => { - material.uniforms.uTime.value += delta - - material.uniforms.inspectingEnabled.value = inspectingEnabled.current ? 1 : 0 - material.uniforms.fadeFactor.value = fadeFactor.current.get() - }) + globalUniforms.uTime.value += delta + globalUniforms.inspectingEnabled.value = inspectingEnabled.current ? 1 : 0 + globalUniforms.fadeFactor.value = fadeFactor.current.get() const cctvUTime = useMesh.getState().cctv?.uTime if (cctvUTime) { diff --git a/src/components/outdoor-cars/index.tsx b/src/components/outdoor-cars/index.tsx index 4f0a0480..4c269f40 100644 --- a/src/components/outdoor-cars/index.tsx +++ b/src/components/outdoor-cars/index.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from "react" -import type { Mesh } from "three" +import { useEffect, useMemo, useRef } from "react" +import { Group, type Mesh } from "three" import { useMesh } from "@/hooks/use-mesh" import { useFrameCallback } from "@/hooks/use-pausable-time" @@ -19,7 +19,7 @@ interface StreetLane { export const OutdoorCars = () => { const { cars } = useMesh() - const [carUpdateCounter, setCarUpdateCounter] = useState(0) + const groupRef = useRef(null) useEffect(() => { if (!cars?.length) return @@ -87,11 +87,17 @@ export const OutdoorCars = () => { } useFrameCallback((_, delta, elapsedTime) => { - let needsUpdate = false + const group = groupRef.current + if (!group) return STREET_LANES.forEach((lane) => { if (!lane.car || !lane.speed || lane.nextStartTime === null) return + // Add car to scene when it first appears + if (lane.car.parent !== group) { + group.add(lane.car) + } + if (!lane.isMoving && elapsedTime >= lane.nextStartTime) { lane.isMoving = true } @@ -112,20 +118,15 @@ export const OutdoorCars = () => { (direction > 0 && lane.car.position.x >= lane.targetPosition[0]) || (direction < 0 && lane.car.position.x <= lane.targetPosition[0]) ) { + const oldCar = lane.car generateRandomCar(lane, elapsedTime) - needsUpdate = true + // Swap cars imperatively — no React re-render needed + group.remove(oldCar) + if (lane.car) group.add(lane.car) } } }) - - if (needsUpdate) { - setCarUpdateCounter((prev) => prev + 1) - } }) - return STREET_LANES.map((lane, index) => { - if (!lane.car) return null - - return - }) + return } diff --git a/src/components/pets/index.tsx b/src/components/pets/index.tsx index b4c78f7b..1c20d667 100644 --- a/src/components/pets/index.tsx +++ b/src/components/pets/index.tsx @@ -5,7 +5,6 @@ import { track } from "@vercel/analytics" import posthog from "posthog-js" import { useEffect, useMemo } from "react" import * as THREE from "three" -import { Color } from "three" import { GLTF } from "three/examples/jsm/Addons.js" import { useCurrentScene } from "@/hooks/use-current-scene" @@ -120,12 +119,12 @@ export function Pets() { const f = 1 - fadeFactor.current.get() if (bostonSkinned && bostonSkinned.material) { const m = bostonSkinned.material as THREE.MeshBasicMaterial - m.color.set(new Color(f, f, f)) + m.color.setScalar(f) } if (pureSkinned && pureSkinned.material) { const m = pureSkinned.material as THREE.MeshBasicMaterial - m.color.set(new Color(f, f, f)) + m.color.setScalar(f) } }) diff --git a/src/components/postprocessing/post-processing.tsx b/src/components/postprocessing/post-processing.tsx index 7cc9ac5c..e3c6bcbd 100644 --- a/src/components/postprocessing/post-processing.tsx +++ b/src/components/postprocessing/post-processing.tsx @@ -1,5 +1,4 @@ import { OrthographicCamera } from "@react-three/drei" -import { useThree } from "@react-three/fiber" import { animate, MotionValue } from "motion" import { memo, useEffect, useMemo, useRef } from "react" import { @@ -137,27 +136,26 @@ const Inner = ({ } }) - const screenWidth = useThree((state) => state.size.width) - const screenHeight = useThree((state) => state.size.height) + const lastScreenSize = useRef({ w: 0, h: 0 }) useEffect(() => { - const controller = new AbortController() - - uniforms.resolution.value.set(screenWidth, screenHeight) uniforms.uPixelRatio.value = Math.min(window.devicePixelRatio, 2) - uniforms.uActiveBloom.value = isMobile ? 0 : 1 - uniforms.uMainTexture.value = mainTexture uniforms.uDepthTexture.value = depthTexture - return () => controller.abort() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mainTexture, depthTexture, isMobile, screenWidth, screenHeight]) + }, [mainTexture, depthTexture, isMobile]) - useFrameCallback((_, __, elapsedTime) => { + useFrameCallback(({ size }, __, elapsedTime) => { uniforms.uTime.value = elapsedTime + + // Update resolution without React re-renders + if (size.width !== lastScreenSize.current.w || size.height !== lastScreenSize.current.h) { + lastScreenSize.current.w = size.width + lastScreenSize.current.h = size.height + uniforms.resolution.value.set(size.width, size.height) + } }) return ( diff --git a/src/components/postprocessing/renderer.tsx b/src/components/postprocessing/renderer.tsx index 007197e1..48fae103 100644 --- a/src/components/postprocessing/renderer.tsx +++ b/src/components/postprocessing/renderer.tsx @@ -1,5 +1,5 @@ -import { createPortal, useThree } from "@react-three/fiber" -import { memo, useEffect, useMemo, useRef } from "react" +import { createPortal } from "@react-three/fiber" +import { memo, useMemo, useRef } from "react" import { DepthTexture, HalfFloatType, @@ -70,14 +70,16 @@ function RendererInner({ sceneChildren }: RendererProps) { const postProcessingCameraRef = useRef(null) const mainCamera = useNavigationStore((state) => state.mainCamera) - const screenWidth = useThree((state) => state.size.width) - const screenHeight = useThree((state) => state.size.height) + const lastSize = useRef({ w: 0, h: 0 }) - useEffect(() => { - mainTarget.setSize(screenWidth, screenHeight) - }, [mainTarget, screenWidth, screenHeight]) + useFrameCallback(({ gl, size }) => { + // Resize render target when viewport changes (avoids useThree re-renders) + if (size.width !== lastSize.current.w || size.height !== lastSize.current.h) { + lastSize.current.w = size.width + lastSize.current.h = size.height + mainTarget.setSize(size.width, size.height) + } - useFrameCallback(({ gl }) => { if (!mainCamera || !postProcessingCameraRef.current || !canRunMainApp) return diff --git a/src/components/routing-element/routing-element.tsx b/src/components/routing-element/routing-element.tsx index 55fc4b1e..529ededc 100644 --- a/src/components/routing-element/routing-element.tsx +++ b/src/components/routing-element/routing-element.tsx @@ -1,10 +1,9 @@ -import { useThree } from "@react-three/fiber" import { useMotionValue, useMotionValueEvent } from "motion/react" import { animate } from "motion/react" import { usePathname, useRouter } from "next/navigation" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { memo } from "react" -import { Mesh, Vector2 } from "three" +import { Mesh } from "three" import { NodeMaterial } from "three/webgpu" import { Fn, @@ -21,7 +20,8 @@ import { smoothstep, fract, mix, - screenUV + screenUV, + viewportSize } from "three/tsl" import { useInspectable } from "@/components/inspectables/context" @@ -46,7 +46,6 @@ const RoutingElementComponent = ({ groupName }: RoutingElementProps) => { const { routingMaterial, routingUniforms } = useMemo(() => { - const uResolution = uniform(new Vector2()) const uOpacity = uniform(0) const uBorderPadding = uniform(0) @@ -89,7 +88,7 @@ const RoutingElementComponent = ({ // Diagonal line pattern using screen coordinates const vCoords = screenUV.toVar() - const aspectRatio = uResolution.x.div(uResolution.y) + const aspectRatio = viewportSize.x.div(viewportSize.y) vCoords.x.mulAssign(aspectRatio) const lineSpacing = 0.006 @@ -117,18 +116,12 @@ const RoutingElementComponent = ({ return { routingMaterial: material, routingUniforms: { - resolution: uResolution, opacity: uOpacity, borderPadding: uBorderPadding } } }, []) - const screenWidth = useThree((state) => state.size.width) - const screenHeight = useThree((state) => state.size.height) - - routingUniforms.resolution.value.set(screenWidth, screenHeight) - const router = useRouter() const pathname = usePathname() const setCursor = useCursor("default") diff --git a/src/components/routing-element/routing-plus.tsx b/src/components/routing-element/routing-plus.tsx index e7f98ad8..ce229836 100644 --- a/src/components/routing-element/routing-plus.tsx +++ b/src/components/routing-element/routing-plus.tsx @@ -1,37 +1,58 @@ import { useThree } from "@react-three/fiber" -import { memo, useMemo, useRef } from "react" +import { memo, useEffect, useMemo, useRef } from "react" import { BufferGeometry, InstancedMesh as InstancedMeshType, Object3D, - PlaneGeometry + PlaneGeometry, + Vector4 } from "three" import { NodeMaterial } from "three/webgpu" -import { Discard, Fn, float, uv, vec4 } from "three/tsl" +import { + cross, + Discard, + Fn, + float, + positionLocal, + uniform, + uv, + vec3, + vec4 +} from "three/tsl" import { useFrameCallback } from "@/hooks/use-pausable-time" const POINT_SIZE = 0.04 -const dummy = new Object3D() const RoutingPlusInner = ({ geometry }: { geometry: BufferGeometry }) => { const meshRef = useRef(null) const camera = useThree((state) => state.camera) - const { planeGeo, material, count, positionsArray } = useMemo(() => { + const { planeGeo, material, count, positionsArray, uCamQ } = useMemo(() => { if (!geometry.attributes.position || !geometry.attributes.normal) - return { planeGeo: null, material: null, count: 0, positionsArray: null } + return { planeGeo: null, material: null, count: 0, positionsArray: null, uCamQ: null } const count = geometry.attributes.position.count const positionsArray = geometry.attributes.position.array as Float32Array const planeGeo = new PlaneGeometry(POINT_SIZE, POINT_SIZE) + const uCamQ = uniform(new Vector4(0, 0, 0, 1)) + const mat = new NodeMaterial() mat.depthTest = false mat.depthWrite = false mat.transparent = true + // GPU billboard: rotate local vertex by camera quaternion + // Quaternion rotation: v' = v + 2w(q × v) + 2(q × (q × v)) + const qVec = vec3(uCamQ.x, uCamQ.y, uCamQ.z) + const c1 = cross(qVec, positionLocal) + const c2 = cross(qVec, c1) + mat.positionNode = positionLocal + .add(c1.mul(uCamQ.w).mul(2.0)) + .add(c2.mul(2.0)) + // Cross/plus pattern using UVs (replaces gl_PointCoord) const coord = uv().mul(2.0).sub(1.0) const thickness = float(0.25) @@ -45,25 +66,32 @@ const RoutingPlusInner = ({ geometry }: { geometry: BufferGeometry }) => { return vec4(1.0, 1.0, 1.0, 1.0) })() - return { planeGeo, material: mat, count, positionsArray } + return { planeGeo, material: mat, count, positionsArray, uCamQ } }, [geometry]) - // Billboard each instance to face the camera (points are always screen-facing) - useFrameCallback(() => { + // Set instance positions once (billboard rotation handled by GPU) + useEffect(() => { if (!meshRef.current || !positionsArray) return + const dummy = new Object3D() for (let i = 0; i < count; i++) { dummy.position.set( positionsArray[i * 3], positionsArray[i * 3 + 1], positionsArray[i * 3 + 2] ) - dummy.quaternion.copy(camera.quaternion) dummy.updateMatrix() meshRef.current.setMatrixAt(i, dummy.matrix) } meshRef.current.instanceMatrix.needsUpdate = true + }, [count, positionsArray]) + + // Single uniform update per frame — O(1) instead of O(n) matrix loop + useFrameCallback(() => { + if (!uCamQ) return + const q = camera.quaternion + uCamQ.value.set(q.x, q.y, q.z, q.w) }) if (!planeGeo || !material || count === 0) return null diff --git a/src/components/scene/index.tsx b/src/components/scene/index.tsx index b7c2e5c5..89d6a167 100644 --- a/src/components/scene/index.tsx +++ b/src/components/scene/index.tsx @@ -2,6 +2,7 @@ import { Canvas } from "@react-three/fiber" import dynamic from "next/dynamic" +import { useSearchParams } from "next/navigation" import { Suspense, useEffect, useRef, useState } from "react" import * as THREE from "three" import { WebGPURenderer } from "three/webgpu" @@ -26,6 +27,7 @@ import { useMinigameStore } from "@/store/minigame-store" import { cn } from "@/utils/cn" import { DoomJs } from "../doom-js" +import { StatsMonitor } from "./stats-monitor" const HoopMinigame = dynamic( () => @@ -60,6 +62,8 @@ export const Scene = () => { const userHasLeftWindow = useRef(false) const [isTouchOnly, setIsTouchOnly] = useState(false) const scene = useCurrentScene() + const searchParams = useSearchParams() + const showPerf = searchParams.has("debug") useTabKeyHandler() @@ -171,6 +175,7 @@ export const Scene = () => { )} > + {showPerf && } { + const statsRef = useRef(null) + + useEffect(() => { + const stats = new Stats() + stats.dom.style.cssText = + "position:fixed;top:0;left:0;z-index:10000;cursor:pointer;opacity:0.9;" + document.body.appendChild(stats.dom) + statsRef.current = stats + + return () => { + document.body.removeChild(stats.dom) + statsRef.current = null + } + }, []) + + useFrame(() => { + statsRef.current?.update() + }) + + return null +} diff --git a/src/components/sparkles/index.tsx b/src/components/sparkles/index.tsx index 989b595f..f8ae2b01 100644 --- a/src/components/sparkles/index.tsx +++ b/src/components/sparkles/index.tsx @@ -1,5 +1,4 @@ import { Sparkles as SparklesImpl } from "@react-three/drei" -import { useMemo } from "react" import * as THREE from "three" import { PointsNodeMaterial } from "three/webgpu" import { @@ -36,91 +35,101 @@ interface SparklesProps { noise?: number | [number, number, number] | THREE.Vector3 | Float32Array } -export const Sparkle = (props: SparklesProps) => { - const { material, uniforms } = useMemo(() => { - const uTime = uniform(0) - const uPixelRatio = uniform(2) - const uFadeFactor = uniform(0) - - const aSize = attribute("size", "float") - const aSpeed = attribute("speed", "float") - const aOpacity = attribute("opacity", "float") - const aNoise = attribute("noise", "vec3") - const aColor = attribute("color", "vec3") - - const mat = new PointsNodeMaterial() - mat.transparent = true - mat.depthWrite = false - mat.sizeAttenuation = false - - // Position with jitter - mat.positionNode = Fn(() => { - const pos = positionLocal.toVar() - pos.x.addAssign( - sin(uTime.mul(aSpeed).add(pos.x.mul(aNoise.x).mul(100.0))).mul(0.2) - ) - pos.y.addAssign( - cos(uTime.mul(aSpeed).add(pos.y.mul(aNoise.y).mul(100.0))).mul(0.2) - ) - pos.z.addAssign( - cos(uTime.mul(aSpeed).add(pos.z.mul(aNoise.z).mul(100.0))).mul(0.2) - ) - return pos - })() - - // Point size: size * pixelRatio, min 1px - ;(mat as any).sizeNode = max(aSize.mul(uPixelRatio), 1.0) - - // Color - mat.colorNode = aColor - - // Opacity with pulse animation - mat.opacityNode = Fn(() => { - const seed = fract( - sin(dot(positionLocal, vec3(12.9898, 78.233, 45.164))).mul(43758.5453) - ).mul(10.0) - const cycle = mod(uTime.mul(aSpeed).add(seed.mul(10.0)), 10.0) - const fadeIn = smoothstep(0.0, 0.3, cycle) - const fadeOut = smoothstep(1.0, 0.7, cycle) - const pulse = float(step(cycle, 1.0)).mul(fadeIn).mul(fadeOut) - return clamp(aOpacity.mul(pulse), 0.0, 1.0) - .mul(0.5) - .mul(float(1.0).sub(uFadeFactor)) - })() - - // drei's Sparkles updates material.time via getter/setter - Object.defineProperty(mat, "time", { - get: () => uTime.value, - set: (v: number) => { - uTime.value = v - } - }) - - return { - material: mat, - uniforms: { - time: uTime, - pixelRatio: uPixelRatio, - fadeFactor: uFadeFactor - } +// --- Shared sparkle material (one shader compilation for all 10 instances) --- + +const sharedSparkleUTime = uniform(0) +const sharedSparklePixelRatio = uniform(2) +const sharedSparkleFadeFactor = uniform(0) + +const sharedSparkleMaterial = (() => { + const aSize = attribute("size", "float") + const aSpeed = attribute("speed", "float") + const aOpacity = attribute("opacity", "float") + const aNoise = attribute("noise", "vec3") + const aColor = attribute("color", "vec3") + + const mat = new PointsNodeMaterial() + mat.transparent = true + mat.depthWrite = false + mat.sizeAttenuation = false + + // Position with jitter + mat.positionNode = Fn(() => { + const pos = positionLocal.toVar() + pos.x.addAssign( + sin( + sharedSparkleUTime + .mul(aSpeed) + .add(pos.x.mul(aNoise.x).mul(100.0)) + ).mul(0.2) + ) + pos.y.addAssign( + cos( + sharedSparkleUTime + .mul(aSpeed) + .add(pos.y.mul(aNoise.y).mul(100.0)) + ).mul(0.2) + ) + pos.z.addAssign( + cos( + sharedSparkleUTime + .mul(aSpeed) + .add(pos.z.mul(aNoise.z).mul(100.0)) + ).mul(0.2) + ) + return pos + })() + + // Point size: size * pixelRatio, min 1px + ;(mat as any).sizeNode = max(aSize.mul(sharedSparklePixelRatio), 1.0) + + // Color + mat.colorNode = aColor + + // Opacity with pulse animation + mat.opacityNode = Fn(() => { + const seed = fract( + sin(dot(positionLocal, vec3(12.9898, 78.233, 45.164))).mul(43758.5453) + ).mul(10.0) + const cycle = mod( + sharedSparkleUTime.mul(aSpeed).add(seed.mul(10.0)), + 10.0 + ) + const fadeIn = smoothstep(0.0, 0.3, cycle) + const fadeOut = smoothstep(1.0, 0.7, cycle) + const pulse = float(step(cycle, 1.0)).mul(fadeIn).mul(fadeOut) + return clamp(aOpacity.mul(pulse), 0.0, 1.0) + .mul(0.5) + .mul(float(1.0).sub(sharedSparkleFadeFactor)) + })() + + // drei's Sparkles updates material.time via getter/setter + Object.defineProperty(mat, "time", { + get: () => sharedSparkleUTime.value, + set: (v: number) => { + sharedSparkleUTime.value = v } - }, []) - - const { fadeFactor } = useFadeAnimation() - - useFrameCallback(() => { - uniforms.fadeFactor.value = fadeFactor.current.get() }) + return mat +})() + +export const Sparkle = (props: SparklesProps) => { return ( - + ) } export const Sparkles = () => { const { isMobile } = useDeviceDetect() + const { fadeFactor } = useFadeAnimation() + + // Single frame callback for all sparkle instances (shared material) + useFrameCallback(() => { + sharedSparkleFadeFactor.value = fadeFactor.current.get() + }) if (isMobile) return null diff --git a/src/hooks/use-gpu.ts b/src/hooks/use-gpu.ts deleted file mode 100644 index 36c82c36..00000000 --- a/src/hooks/use-gpu.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useEffect, useState } from "react" - -export const useGpu = () => { - const [gpuEnabled, setGpuEnabled] = useState(true) - - useEffect(() => { - const hasWebGPU = typeof navigator !== "undefined" && "gpu" in navigator - setGpuEnabled(hasWebGPU) - }, []) - - return gpuEnabled -} diff --git a/src/hooks/use-handle-contact.ts b/src/hooks/use-handle-contact.ts index 29195d7a..0c112cd7 100644 --- a/src/hooks/use-handle-contact.ts +++ b/src/hooks/use-handle-contact.ts @@ -3,7 +3,6 @@ import { useCallback, useRef } from "react" import { useContactStore } from "@/components/contact/contact-store" import { useAppLoadingStore } from "@/components/loading/app-loading-handler" -import { useGpu } from "@/hooks/use-gpu" export const useHandleContactButton = () => { const setIsContactOpen = useContactStore((state) => state.setIsContactOpen) @@ -11,14 +10,13 @@ export const useHandleContactButton = () => { const isAnimating = useContactStore((state) => state.isAnimating) const canRunMainApp = useAppLoadingStore((state) => state.canRunMainApp) const router = useRouter() - const webglEnabled = useGpu() const clickTimeoutRef = useRef(null) const handleClick = useCallback(() => { if (clickTimeoutRef.current || isAnimating || !canRunMainApp) return const isMobile = window.innerWidth < 1024 - if (webglEnabled && !isMobile) { + if (canRunMainApp && !isMobile) { setIsContactOpen(!isContactOpen) clickTimeoutRef.current = setTimeout(() => { @@ -28,7 +26,6 @@ export const useHandleContactButton = () => { router.push("/contact") } }, [ - webglEnabled, isContactOpen, setIsContactOpen, router, diff --git a/src/shaders/material-flow/index.ts b/src/shaders/material-flow/index.ts index aa848bd5..06250def 100644 --- a/src/shaders/material-flow/index.ts +++ b/src/shaders/material-flow/index.ts @@ -15,7 +15,7 @@ import { mx_noise_float } from "three/tsl" -const FLOW_RESOLUTION = 1024 +const FLOW_RESOLUTION = 512 export const createFlowMaterial = () => { const uFrame = uniform(0) diff --git a/src/shaders/material-global-shader/index.tsx b/src/shaders/material-global-shader/index.tsx index 302bfc64..976b945f 100644 --- a/src/shaders/material-global-shader/index.tsx +++ b/src/shaders/material-global-shader/index.tsx @@ -2,7 +2,6 @@ import { Color, Matrix3, MeshStandardMaterial, - ShaderMaterial, Texture, Vector2, Vector3 @@ -34,12 +33,28 @@ import { mod, select } from "three/tsl" -import { create } from "zustand" - import { basicLight } from "@/shaders/utils/basic-light" export const GLOBAL_SHADER_MATERIAL_NAME = "global-shader-material" +// --- Shared blank texture (reused as placeholder for all texture slots) --- +const BLANK_TEXTURE = new Texture() + +// --- Shared global uniforms (same value across all materials) --- +const sharedUTime = uniform(0) +const sharedInspectingEnabled = uniform(0) +const sharedFadeFactor = uniform(0) +const sharedFogColor = uniform(new Vector3(0.2, 0.2, 0.2)) +const sharedFogDensity = uniform(0.05) +const sharedFogDepth = uniform(9.0) +const sharedNoiseFactor = uniform(0.5) + +export const globalUniforms = { + uTime: sharedUTime, + inspectingEnabled: sharedInspectingEnabled, + fadeFactor: sharedFadeFactor +} as const + export const createGlobalShaderMaterial = ( baseMaterial: MeshStandardMaterial, defines?: { @@ -89,31 +104,31 @@ export const createGlobalShaderMaterial = ( const uColor = uniform(emissiveColor) const uBaseColor = uniform(baseColorVal) - const uMap = texture(map || new Texture()) + const uMap = texture(map || BLANK_TEXTURE) const uMapMatrix = uniform(new Matrix3().identity()) const uMapRepeat = uniform( map ? new Vector2(map.repeat.x, map.repeat.y) : new Vector2(1, 1) ) - const uLightMap = texture(new Texture()) + const uLightMap = texture(BLANK_TEXTURE) const uLightMapIntensity = uniform(0) - const uAoMap = texture(new Texture()) + const uAoMap = texture(BLANK_TEXTURE) const uAoMapIntensity = uniform(0) const uOpacity = uniform(baseOpacity) - const uTime = uniform(0) - const uNoiseFactor = uniform(0.5) - const uAlphaMap = texture(alphaMap || new Texture()) + const uTime = sharedUTime + const uNoiseFactor = sharedNoiseFactor + const uAlphaMap = texture(alphaMap || BLANK_TEXTURE) const uAlphaMapTransform = uniform(new Matrix3().identity()) const uEmissive = uniform(baseMaterial.emissive || new Vector3()) const uEmissiveIntensity = uniform(baseMaterial.emissiveIntensity || 0) - const uEmissiveMap = texture(emissiveMap || new Texture()) - const uFogColor = uniform(new Vector3(0.2, 0.2, 0.2)) - const uFogDensity = uniform(0.05) - const uFogDepth = uniform(9.0) - const uGlassReflex = texture(new Texture()) - const uInspectingEnabled = uniform(0) + const uEmissiveMap = texture(emissiveMap || BLANK_TEXTURE) + const uFogColor = sharedFogColor + const uFogDensity = sharedFogDensity + const uFogDepth = sharedFogDepth + const uGlassReflex = texture(BLANK_TEXTURE) + const uInspectingEnabled = sharedInspectingEnabled const uInspectingFactor = uniform(0) - const uFadeFactor = uniform(0) - const uLampLightmap = texture(new Texture()) + const uFadeFactor = sharedFadeFactor + const uLampLightmap = texture(BLANK_TEXTURE) const uLightLampEnabled = uniform(0) // Conditional uniforms (only created when the feature is active) @@ -139,7 +154,7 @@ export const createGlobalShaderMaterial = ( } if (isMatcap) { - uMatcapTex = texture(new Texture()) + uMatcapTex = texture(BLANK_TEXTURE) uGlassMatcap = uniform(0) } @@ -455,31 +470,7 @@ export const createGlobalShaderMaterial = ( material.needsUpdate = true - useCustomShaderMaterial - .getState() - .addMaterial(material as unknown as ShaderMaterial) - baseMaterial.dispose() - return material as unknown as ShaderMaterial + return material as any } - -interface CustomShaderMaterialStore { - materialsRef: Record - addMaterial: (material: ShaderMaterial) => void - removeMaterial: (id: number) => void -} - -export const useCustomShaderMaterial = create( - (_set, get) => ({ - materialsRef: {}, - addMaterial: (material) => { - const materials = get().materialsRef - materials[material.id] = material - }, - removeMaterial: (id) => { - const materials = get().materialsRef - delete materials[id] - } - }) -) diff --git a/src/shaders/material-postprocessing/index.ts b/src/shaders/material-postprocessing/index.ts index 003aa37d..89a67534 100644 --- a/src/shaders/material-postprocessing/index.ts +++ b/src/shaders/material-postprocessing/index.ts @@ -29,7 +29,7 @@ import { int } from "three/tsl" -const SAMPLE_COUNT = 24 +const SAMPLE_COUNT = 16 const GOLDEN_ANGLE = 2.399963229728653 const PI_2 = 6.28318530718 From 168e5d0993a6b86ffb8e1de465fe10fc1a1f6380 Mon Sep 17 00:00:00 2001 From: ragojose Date: Thu, 5 Feb 2026 12:43:31 -0300 Subject: [PATCH 19/21] perf: move CPU-bound per-frame work to GPU via TSL - CRT mesh: replace CPU-pumped uTime with timerLocal() (GPU-side time) - Postprocessing: remove dead uTime uniform (declared but never used in shader graph) - Arcade grid: delete entire dead Grid component and uniforms utility (never imported) - Weather rain: move UV scroll from CPU Matrix3.setUvTransform() to TSL shader graph using shared uTime with alphaMapScrollSpeed/alphaMapRepeat uniforms - Christmas tree: move 4-phase ornament cycling and star oscillation to TSL via timerGlobal() + smoothstep, eliminating per-frame O(N) mesh iteration - Global shader: add ORNAMENT/ORNAMENT_STAR compile-time flags, alphaMap scroll uniforms, and GPU-computed emissive intensity modulation Co-Authored-By: Claude Opus 4.5 --- src/components/arcade-game/entities/grid.tsx | 167 ---------------- src/components/arcade-game/lib/uniforms.ts | 22 --- src/components/christmas-tree/client.tsx | 183 +++--------------- src/components/doom-js/crt-mesh.tsx | 11 +- .../postprocessing/post-processing.tsx | 4 +- src/components/weather/index.tsx | 30 +-- src/shaders/material-global-shader/index.tsx | 59 +++++- src/shaders/material-postprocessing/index.ts | 2 - 8 files changed, 98 insertions(+), 380 deletions(-) delete mode 100644 src/components/arcade-game/entities/grid.tsx delete mode 100644 src/components/arcade-game/lib/uniforms.ts diff --git a/src/components/arcade-game/entities/grid.tsx b/src/components/arcade-game/entities/grid.tsx deleted file mode 100644 index eee5da0a..00000000 --- a/src/components/arcade-game/entities/grid.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { useThree } from "@react-three/fiber" -import { useMemo, useRef } from "react" -import type { LineSegments } from "three" -import { Group, ShaderMaterial, Vector2 } from "three" -import { Line2 } from "three/examples/jsm/lines/Line2.js" -import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js" -import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js" - -import { useFrameCallback } from "@/hooks/use-pausable-time" - -import { COLORS } from "../lib/colors" -import { setMaterialUniforms } from "../lib/uniforms" - -export interface GridProps { - position?: [number, number, number] - size: number - divisions: [number, number] - /** Whether to generate the grid final lines */ - caps?: boolean -} - -const gridMaterial = new ShaderMaterial({ - uniforms: { - u_color: { value: COLORS.cyan }, - u_color2: { value: COLORS.violet }, - u_cameraPosition: { value: [0, 0, 0] }, - u_lineWidth: { value: 19.0 } - }, - vertexShader: /* glsl */ ` - uniform float u_lineWidth; - attribute float lineDirection; - varying vec3 v_worldPosition; - varying float v_lineDirection; - - void main() { - v_lineDirection = lineDirection; - v_worldPosition = (modelMatrix * vec4(position, 1.0)).xyz; - - vec4 viewPos = modelViewMatrix * vec4(position, 1.0); - vec4 projectedPos = projectionMatrix * viewPos; - gl_Position = projectedPos; - gl_PointSize = u_lineWidth; - } - `, - fragmentShader: /* glsl */ ` - uniform vec3 u_color; - uniform vec3 u_color2; - uniform vec3 u_cameraPosition; - varying vec3 v_worldPosition; - varying float v_lineDirection; - - float valueRemap(float value, float low1, float high1, float low2, float high2) { - return low2 + (value - low1) * (high2 - low2) / (high1 - low1); - } - - void main() { - float distance = length(v_worldPosition - u_cameraPosition); - - float fadeEnd = mix(300.0, 50.0, v_lineDirection); - float fade = valueRemap(distance, 0.0, fadeEnd, 1.0, 0.0); - vec3 color = mix(u_color, u_color2, v_lineDirection); - gl_FragColor = vec4(color, clamp(fade, 0.0, 1.0)); - } - `, - transparent: true -}) - -export const Grid = ({ - position = [0, 0, 0], - size, - divisions, - caps = false -}: GridProps) => { - const camera = useThree((state) => state.camera) - const lineRef = useRef(null) - - const [divisionsX, divisionsY] = useMemo(() => { - if (Array.isArray(divisions)) { - return divisions - } - - return [divisions, divisions] - }, [divisions]) - - const lines = useMemo(() => { - const group = new Group() - - const stepX = (size * 2) / divisionsX - - for (let i = 0; i <= divisionsX; i++) { - const position = -size + i * stepX - - const positions = [position, 0, -size, position, 0, size] - - const geometry = new LineGeometry() - geometry.setPositions(positions) - - const material = new LineMaterial({ - color: COLORS.cyan, - linewidth: 5, - resolution: new Vector2(window.innerWidth, window.innerHeight), - transparent: true, - opacity: 0.8 - }) - - const line = new Line2(geometry, material) - group.add(line) - } - - const stepY = (size * 2) / divisionsY - - for (let i = 0; i <= divisionsY; i++) { - const position = -size + i * stepY - - const positions = [-size, 0, position, size, 0, position] - - const geometry = new LineGeometry() - geometry.setPositions(positions) - - const material = new LineMaterial({ - color: COLORS.violet, - linewidth: 5, - resolution: new Vector2(window.innerWidth, window.innerHeight), - transparent: true, - opacity: 0.8 - }) - - const line = new Line2(geometry, material) - group.add(line) - } - - if (caps) { - const bottomGeometry = new LineGeometry() - bottomGeometry.setPositions([-size, 0, -size, size, 0, -size]) - - const topGeometry = new LineGeometry() - topGeometry.setPositions([-size, 0, size, size, 0, size]) - - const material = new LineMaterial({ - color: COLORS.violet, - linewidth: 5, - resolution: new Vector2(window.innerWidth, window.innerHeight), - transparent: true, - opacity: 0.8 - }) - - group.add(new Line2(bottomGeometry, material.clone())) - group.add(new Line2(topGeometry, material.clone())) - } - - return group - }, [size, divisionsX, divisionsY, caps]) - - useFrameCallback(() => { - if (lineRef.current) { - setMaterialUniforms(gridMaterial, { - u_cameraPosition: camera.position - }) - } - }) - - return ( - - - - ) -} diff --git a/src/components/arcade-game/lib/uniforms.ts b/src/components/arcade-game/lib/uniforms.ts deleted file mode 100644 index 8f0eb56b..00000000 --- a/src/components/arcade-game/lib/uniforms.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Material, ShaderMaterial } from "three" - -export const setMaterialUniforms = ( - material: Material | ShaderMaterial, - uniforms: Record -) => { - if ( - material.isMaterial === true && - (material as any).uniforms !== undefined - ) { - Object.entries(uniforms).forEach(([key, value]) => { - if ((material as any).uniforms[key] === undefined) { - console.warn(`Uniform ${key} does not exist in material`, material.id) - return - } else { - ;(material as any).uniforms[key].value = value - } - }) - } else { - console.warn("Material is not a material") - } -} diff --git a/src/components/christmas-tree/client.tsx b/src/components/christmas-tree/client.tsx index 970e5bea..9ed8d42b 100644 --- a/src/components/christmas-tree/client.tsx +++ b/src/components/christmas-tree/client.tsx @@ -1,9 +1,6 @@ -import { useTexture } from "@react-three/drei" - import { MeshDiscardMaterial } from "@/components/mesh-discard-material" -import { useFrame } from "@react-three/fiber" -import { useCallback, useEffect, useRef } from "react" -import { Color, Mesh, ShaderMaterial, Vector3 } from "three" +import { useCallback, useEffect } from "react" +import { Color, Mesh, Vector3 } from "three" import { GLTF } from "three/examples/jsm/Addons.js" import { useAssets } from "@/components/assets-provider" @@ -13,9 +10,6 @@ import { useCursor } from "@/hooks/use-mouse" import { useSiteAudio, useSiteAudioStore } from "@/hooks/use-site-audio" import { createGlobalShaderMaterial } from "@/shaders/material-global-shader" -const CYCLE_TIME = 1.5 -const TRANSITION_OVERLAP = 0.75 - const COLORS = { blue: new Color("#64aaeb"), green: new Color("#00ff00"), @@ -32,9 +26,13 @@ const INTENSITIES = { star: 16 } -// Pre-allocated for per-frame star color animation (avoids new Color() each frame) -const _animatedColor = new Color() -const _hsl = { h: 0, s: 0, l: 0 } +// Phase offsets matching the original 4-phase cycling order +const ORNAMENT_PHASES: Record = { + Green: 0, + Yellow: 1, + Red: 2, + Blue: 3 +} export const ClientChristmasTree = () => { const { specialEvents } = useAssets() @@ -45,13 +43,6 @@ export const ClientChristmasTree = () => { const { scene } = useKTX2GLTF(specialEvents.christmas.tree) const { services } = useMesh() - const ballMeshRefs = useRef>({ - blue: [], - green: [], - red: [], - yellow: [], - star: [] - }) useEffect(() => { if (!scene) return @@ -63,154 +54,42 @@ export const ClientChristmasTree = () => { const material = child.material as any const materialName = material?.name || "" + // Detect ornament type before creating material + const ornamentPhase = ORNAMENT_PHASES[materialName] + const isStar = materialName === "Star" + const isOrnament = ornamentPhase !== undefined + const isMatcap = + materialName === "RedMetallic" || materialName === "BlueMetallic" + child.material = createGlobalShaderMaterial(child.material, { LIGHT: true, - MATCAP: - materialName === "RedMetallic" || materialName === "BlueMetallic" + MATCAP: isMatcap, + ORNAMENT: isOrnament, + ORNAMENT_STAR: isStar }) child.material.side = 2 - child.material.uniforms.lightDirection.value = new Vector3(1, 0, -1) - child.userData.hasGlobalMaterial = true - const ornamentTypes = [ - "Blue", - "Green", - "Red", - "Yellow", - "Star" - ] as const - - const ornamentType = ornamentTypes.find((type) => materialName === type) - if (ornamentType) { - ballMeshRefs.current[ornamentType.toLowerCase()].push(child) + // Set ornament uniforms once — cycling computed on GPU via timerGlobal() + if (isOrnament) { + const key = materialName.toLowerCase() as keyof typeof COLORS + child.material.uniforms.uColor.value = COLORS[key] + child.material.uniforms.emissive.value = COLORS[key] + child.material.uniforms.ornamentPhase.value = ornamentPhase + child.material.uniforms.ornamentBaseIntensity.value = INTENSITIES[key] + } - switch (ornamentType) { - case "Blue": - child.material.uniforms.uColor.value = COLORS.blue - child.material.uniforms.emissive.value = COLORS.blue - child.material.uniforms.emissiveIntensity.value = INTENSITIES.blue - break - case "Green": - child.material.uniforms.uColor.value = COLORS.green - child.material.uniforms.emissive.value = COLORS.green - child.material.uniforms.emissiveIntensity.value = - INTENSITIES.green - break - case "Red": - child.material.uniforms.uColor.value = COLORS.red - child.material.uniforms.emissive.value = COLORS.red - child.material.uniforms.emissiveIntensity.value = INTENSITIES.red - break - case "Yellow": - child.material.uniforms.uColor.value = COLORS.yellow - child.material.uniforms.emissive.value = COLORS.yellow - child.material.uniforms.emissiveIntensity.value = - INTENSITIES.yellow - break - case "Star": - child.material.uniforms.uColor.value = COLORS.star - child.material.uniforms.emissive.value = COLORS.star - child.material.uniforms.emissiveIntensity.value = INTENSITIES.star - break - } + if (isStar) { + child.material.uniforms.uColor.value = COLORS.star + child.material.uniforms.emissive.value = COLORS.star + child.material.uniforms.ornamentBaseIntensity.value = INTENSITIES.star } } }) }, [scene]) - useFrame((state) => { - const time = state.clock.elapsedTime - - const phaseTime = CYCLE_TIME / 4 - const cyclePhase = (time % CYCLE_TIME) / phaseTime - - const getTransition = (targetPhase: number) => { - let localPhase = cyclePhase - while (localPhase < 0) localPhase += 4 - while (localPhase >= 4) localPhase -= 4 - - let phaseDiff = localPhase - targetPhase - - if (phaseDiff > 2) phaseDiff -= 4 - if (phaseDiff <= -2) phaseDiff += 4 - - const fadeInStart = -TRANSITION_OVERLAP - const fadeInEnd = 0.0 - const fadeOutStart = 1.0 - TRANSITION_OVERLAP - const fadeOutEnd = 1.0 - - if (phaseDiff >= fadeInStart && phaseDiff < fadeInEnd) { - const progress = (phaseDiff - fadeInStart) / TRANSITION_OVERLAP - return Math.sin(progress * Math.PI * 0.5) - } - - if (phaseDiff >= 0 && phaseDiff < fadeOutStart) { - return 1.0 - } - - if (phaseDiff >= fadeOutStart && phaseDiff < fadeOutEnd) { - const progress = (phaseDiff - fadeOutStart) / TRANSITION_OVERLAP - return Math.sin((1 - progress) * Math.PI * 0.5) - } - - return 0 - } - - const greenIntensityMultiplier = getTransition(0) - const yellowIntensityMultiplier = getTransition(1) - const redIntensityMultiplier = getTransition(2) - const blueIntensityMultiplier = getTransition(3) - - ballMeshRefs.current.green.forEach((mesh) => { - if (mesh.material && "uniforms" in mesh.material) { - const material = mesh.material as ShaderMaterial - const intensity = INTENSITIES.green * greenIntensityMultiplier - material.uniforms.emissiveIntensity.value = intensity - } - }) - - ballMeshRefs.current.yellow.forEach((mesh) => { - if (mesh.material && "uniforms" in mesh.material) { - const material = mesh.material as ShaderMaterial - const intensity = INTENSITIES.yellow * yellowIntensityMultiplier - material.uniforms.emissiveIntensity.value = intensity - } - }) - - ballMeshRefs.current.red.forEach((mesh) => { - if (mesh.material && "uniforms" in mesh.material) { - const material = mesh.material as ShaderMaterial - const intensity = INTENSITIES.red * redIntensityMultiplier - material.uniforms.emissiveIntensity.value = intensity - } - }) - - ballMeshRefs.current.blue.forEach((mesh) => { - if (mesh.material && "uniforms" in mesh.material) { - const material = mesh.material as ShaderMaterial - const intensity = INTENSITIES.blue * blueIntensityMultiplier - material.uniforms.emissiveIntensity.value = intensity - } - }) - - COLORS.star.getHSL(_hsl) - const animatedHue = (_hsl.h + 0.1 * Math.sin(time * 1.2)) % 1 - _animatedColor.setHSL(animatedHue, _hsl.s, _hsl.l) - const starOscillation = Math.abs(Math.sin(time * 2.5)) - const animatedStarIntensity = - INTENSITIES.star + INTENSITIES.star * starOscillation - ballMeshRefs.current.star.forEach((mesh, i) => { - if (mesh.material && "uniforms" in mesh.material) { - const material = mesh.material as ShaderMaterial - material.uniforms.emissive.value = _animatedColor - material.uniforms.emissiveIntensity.value = animatedStarIntensity - } - }) - }) - useEffect(() => { if (services.pot) services.pot.visible = false }, [services.pot]) diff --git a/src/components/doom-js/crt-mesh.tsx b/src/components/doom-js/crt-mesh.tsx index 00d6fb04..9cfb1a44 100644 --- a/src/components/doom-js/crt-mesh.tsx +++ b/src/components/doom-js/crt-mesh.tsx @@ -18,14 +18,13 @@ import { clamp, pow, mix, - step + step, + timerLocal } from "three/tsl" -import { useFrameCallback } from "@/hooks/use-pausable-time" - const createCRTMaterial = () => { const uTexture = tslTexture(new Texture()) - const uTime = uniform(0) + const uTime = timerLocal() const uCurvature = uniform(0.3) const uScanlineIntensity = uniform(0.75) const uScanlineCount = uniform(200) @@ -167,10 +166,6 @@ export function CRTMesh({ texture }: CRTMeshProps) { uniforms.uTexture.value = texture }, [texture, uniforms]) - useFrameCallback((state) => { - uniforms.uTime.value = state.clock.getElapsedTime() - }) - return ( diff --git a/src/components/postprocessing/post-processing.tsx b/src/components/postprocessing/post-processing.tsx index e3c6bcbd..7ac23fc5 100644 --- a/src/components/postprocessing/post-processing.tsx +++ b/src/components/postprocessing/post-processing.tsx @@ -147,9 +147,7 @@ const Inner = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [mainTexture, depthTexture, isMobile]) - useFrameCallback(({ size }, __, elapsedTime) => { - uniforms.uTime.value = elapsedTime - + useFrameCallback(({ size }) => { // Update resolution without React re-renders if (size.width !== lastScreenSize.current.w || size.height !== lastScreenSize.current.h) { lastScreenSize.current.w = size.width diff --git a/src/components/weather/index.tsx b/src/components/weather/index.tsx index 8448b0d3..d012c31c 100644 --- a/src/components/weather/index.tsx +++ b/src/components/weather/index.tsx @@ -3,7 +3,6 @@ import { animate, useMotionValue } from "motion/react" import { useEffect, useMemo } from "react" import { Color, - Matrix3, Mesh, MeshStandardMaterial, RepeatWrapping, @@ -31,9 +30,6 @@ export const Weather = () => { const rainAlphaTexture = useTexture(rainTexture) - const closeMatrix = useMemo(() => new Matrix3().identity(), []) - const farMatrix = useMemo(() => new Matrix3().identity(), []) - const loboMarino = useMesh((s) => s.weather.loboMarino) const [rainMaterialClose, rainMaterialFar] = useMemo(() => { @@ -46,18 +42,18 @@ export const Weather = () => { transparent: true }) - // create materialClose + // create materialClose — UV scroll computed in TSL via shared uTime const rainMaterialClose = createGlobalShaderMaterial(baseMaterial as any) - - rainMaterialClose.uniforms.mapMatrix.value = closeMatrix + rainMaterialClose.uniforms.alphaMapScrollSpeed.value = 1.5 + rainMaterialClose.uniforms.alphaMapRepeat.value = 2 // create materialFar const rainMaterialFar = createGlobalShaderMaterial(baseMaterial as any) - farMatrix.multiplyScalar(4) - rainMaterialFar.uniforms.mapMatrix.value = farMatrix + rainMaterialFar.uniforms.alphaMapScrollSpeed.value = 3 + rainMaterialFar.uniforms.alphaMapRepeat.value = 4 return [rainMaterialClose, rainMaterialFar] - }, [rainAlphaTexture, closeMatrix, farMatrix]) + }, [rainAlphaTexture]) const { rain } = useMesh((s) => s.weather) @@ -81,20 +77,6 @@ export const Weather = () => { return () => animation.stop() }, [isRaining, rainMaterialClose, rainMaterialFar, rainAlpha]) - useFrameCallback((_, delta, elapsedTime) => { - const matClose = rainMaterialClose.uniforms.alphaMapTransform - .value as Matrix3 - - const closeRepeat = 2 - const closeOffsetY = elapsedTime * 1.5 - matClose.setUvTransform(0, closeOffsetY, closeRepeat, closeRepeat, 0, 0, 0) - - const matFar = rainMaterialFar.uniforms.alphaMapTransform.value as Matrix3 - - const farRepeat = 4 - const farOffsetY = elapsedTime * 3 - matFar.setUvTransform(0, farOffsetY, farRepeat, farRepeat, 0, 0, 0) - }) return ( <> diff --git a/src/shaders/material-global-shader/index.tsx b/src/shaders/material-global-shader/index.tsx index 976b945f..39e0624c 100644 --- a/src/shaders/material-global-shader/index.tsx +++ b/src/shaders/material-global-shader/index.tsx @@ -31,7 +31,11 @@ import { max, floor, mod, - select + select, + timerGlobal, + smoothstep, + sin, + abs } from "three/tsl" import { basicLight } from "@/shaders/utils/basic-light" @@ -68,6 +72,8 @@ export const createGlobalShaderMaterial = ( CLOUDS?: boolean DAYLIGHT?: boolean IS_LOBO_MARINO?: boolean + ORNAMENT?: boolean + ORNAMENT_STAR?: boolean } ) => { const { @@ -99,6 +105,8 @@ export const createGlobalShaderMaterial = ( const isClouds = Boolean(defines?.CLOUDS) const isDaylight = Boolean(defines?.DAYLIGHT) const isLoboMarino = Boolean(defines?.IS_LOBO_MARINO) + const isOrnament = Boolean(defines?.ORNAMENT) + const isOrnamentStar = Boolean(defines?.ORNAMENT_STAR) // --- TSL Uniform Nodes --- @@ -118,6 +126,8 @@ export const createGlobalShaderMaterial = ( const uNoiseFactor = sharedNoiseFactor const uAlphaMap = texture(alphaMap || BLANK_TEXTURE) const uAlphaMapTransform = uniform(new Matrix3().identity()) + const uAlphaMapScrollSpeed = uniform(0) + const uAlphaMapRepeat = uniform(1) const uEmissive = uniform(baseMaterial.emissive || new Vector3()) const uEmissiveIntensity = uniform(baseMaterial.emissiveIntensity || 0) const uEmissiveMap = texture(emissiveMap || BLANK_TEXTURE) @@ -162,6 +172,18 @@ export const createGlobalShaderMaterial = ( uDaylight = uniform(1) } + let uOrnamentPhase: ReturnType | undefined + let uOrnamentBaseIntensity: ReturnType | undefined + + if (isOrnament) { + uOrnamentPhase = uniform(0) + uOrnamentBaseIntensity = uniform(0) + } + + if (isOrnamentStar) { + uOrnamentBaseIntensity = uOrnamentBaseIntensity || uniform(0) + } + // --- Material --- const material = new NodeMaterial() @@ -223,6 +245,23 @@ export const createGlobalShaderMaterial = ( .mul(mix(float(1.0), oneMinusFadeFactor, shouldFadeF)) .toVar() + // Ornament cycling: 4-phase sine transition computed on GPU + if (isOrnament) { + const t = timerGlobal() + const cyclePhase = t.mod(1.5).div(0.375) // [0, 4) + const pd = cyclePhase.sub(uOrnamentPhase!).add(2.0).mod(4.0).sub(2.0) + const transUp = smoothstep(float(-0.75), float(0.0), pd) + const transDown = float(1.0).sub(smoothstep(float(0.25), float(1.0), pd)) + ei.assign(uOrnamentBaseIntensity!.mul(transUp.mul(transDown))) + } + + // Star ornament: intensity oscillation computed on GPU + if (isOrnamentStar) { + const t = timerGlobal() + const starOsc = abs(sin(t.mul(2.5))) + ei.assign(uOrnamentBaseIntensity!.mul(float(1.0).add(starOsc))) + } + if (useEmissive) { irradiance.addAssign(uEmissive.mul(ei)) } @@ -293,9 +332,14 @@ export const createGlobalShaderMaterial = ( } if (useAlphaMap) { - const alphaUv = uAlphaMapTransform + const matrixUv = uAlphaMapTransform .mul(vec3(vUv1.x, vUv1.y, float(1.0))) .xy + const scrollUv = vUv1.mul(uAlphaMapRepeat).add( + vec2(float(0.0), uTime.mul(uAlphaMapScrollSpeed)) + ) + const scrollActive = step(float(0.001), uAlphaMapScrollSpeed) + const alphaUv = mix(matrixUv, scrollUv, scrollActive) opacityResult.mulAssign(uAlphaMap.sample(alphaUv).r) } @@ -430,6 +474,8 @@ export const createGlobalShaderMaterial = ( uTime, alphaMap: uAlphaMap, alphaMapTransform: uAlphaMapTransform, + alphaMapScrollSpeed: uAlphaMapScrollSpeed, + alphaMapRepeat: uAlphaMapRepeat, emissive: uEmissive, emissiveIntensity: uEmissiveIntensity, fogColor: uFogColor, @@ -466,6 +512,15 @@ export const createGlobalShaderMaterial = ( uniformsCompat.daylight = uDaylight! } + if (isOrnament) { + uniformsCompat.ornamentPhase = uOrnamentPhase! + uniformsCompat.ornamentBaseIntensity = uOrnamentBaseIntensity! + } + + if (isOrnamentStar) { + uniformsCompat.ornamentBaseIntensity = uOrnamentBaseIntensity! + } + ;(material as any).uniforms = uniformsCompat material.needsUpdate = true diff --git a/src/shaders/material-postprocessing/index.ts b/src/shaders/material-postprocessing/index.ts index 89a67534..051028b9 100644 --- a/src/shaders/material-postprocessing/index.ts +++ b/src/shaders/material-postprocessing/index.ts @@ -40,7 +40,6 @@ export const createPostProcessingMaterial = () => { const uDepthTexture = texture(new Texture()) const uResolution = uniform(new Vector2(1, 1)) const uPixelRatio = uniform(1) - const uTime = uniform(0) const uOpacity = uniform(1) const uActiveBloom = uniform(1) const uContrast = uniform(1) @@ -220,7 +219,6 @@ export const createPostProcessingMaterial = () => { uDepthTexture, resolution: uResolution, uPixelRatio, - uTime, uOpacity, uActiveBloom, uContrast, From 985420838f261910aac26fc3d3122b836353cfcf Mon Sep 17 00:00:00 2001 From: ragojose Date: Fri, 6 Feb 2026 12:52:46 -0300 Subject: [PATCH 20/21] fix: lamp lighting, texture uploads, character face UVs for WebGPU - Add needsUpdate after texture property changes in BakesLoader and Lamp to force GPU re-upload with correct flipY/filter/colorSpace settings - Replace empty Texture() placeholder with 1x1 white DataTexture for valid WebGPU pipeline compilation - Fix character face double-transform by passing uv() to TextureNode constructor to disable automatic matrix application - Replace @react-three/uikit with TSL-based arcade UI (panel, text, image) - Misc shader and renderer fixes for WebGPU compatibility Co-Authored-By: Claude Opus 4.6 --- ...026-02-05-gpu-performance-120fps-design.md | 41 ++ .../2026-02-06-lamp-webgpu-fix-design.md | 121 +++++ package.json | 2 - pnpm-lock.yaml | 120 ----- src/components/arcade-game/index.tsx | 120 +++-- .../arcade-ui-components/arcade-featured.tsx | 176 -------- .../arcade-ui-components/arcade-labs-list.tsx | 331 -------------- .../arcade-ui-components/arcade-preview.tsx | 37 -- .../arcade-title-tags-header.tsx | 52 --- .../arcade-wrapper-tags.tsx | 92 ---- .../arcade-screen/arcade-ui/constants.ts | 58 +++ .../arcade-screen/arcade-ui/index.tsx | 208 +++++++++ .../arcade-screen/arcade-ui/msdf-font.ts | 175 ++++++++ .../arcade-ui/sections/featured.tsx | 160 +++++++ .../arcade-ui/sections/labs-list.tsx | 417 ++++++++++++++++++ .../arcade-ui/sections/preview.tsx | 70 +++ .../arcade-ui/sections/title-header.tsx | 70 +++ .../arcade-ui/sections/wrapper-tags.tsx | 106 +++++ .../arcade-screen/arcade-ui/ui-image.tsx | 61 +++ .../arcade-screen/arcade-ui/ui-panel.tsx | 99 +++++ .../arcade-screen/arcade-ui/ui-text.tsx | 71 +++ src/components/arcade-screen/index.tsx | 9 +- src/components/arcade-screen/screen-ui.tsx | 168 ------- src/components/doom-js/crt-mesh.tsx | 4 +- src/components/lamp/index.tsx | 15 +- .../loading/loading-scene/index.tsx | 8 +- src/components/map/bakes.tsx | 22 +- src/components/postprocessing/renderer.tsx | 38 +- src/components/scene/index.tsx | 13 +- src/hooks/use-preload-assets.ts | 24 +- src/hooks/use-video-resume.ts | 1 + src/shaders/material-arcade-image/index.ts | 49 ++ src/shaders/material-arcade-panel/index.ts | 103 +++++ src/shaders/material-arcade-text/index.ts | 57 +++ src/shaders/material-characters/index.ts | 4 +- src/shaders/material-flow/index.ts | 16 +- src/shaders/material-global-shader/index.tsx | 28 +- src/shaders/material-net/index.ts | 7 +- src/shaders/material-postprocessing/index.ts | 2 +- src/shaders/material-screen/index.ts | 4 +- src/shaders/material-solid-reveal/index.ts | 22 +- src/store/arcade-store.ts | 2 +- 42 files changed, 2069 insertions(+), 1114 deletions(-) create mode 100644 docs/plans/2026-02-05-gpu-performance-120fps-design.md create mode 100644 docs/plans/2026-02-06-lamp-webgpu-fix-design.md delete mode 100644 src/components/arcade-screen/arcade-ui-components/arcade-featured.tsx delete mode 100644 src/components/arcade-screen/arcade-ui-components/arcade-labs-list.tsx delete mode 100644 src/components/arcade-screen/arcade-ui-components/arcade-preview.tsx delete mode 100644 src/components/arcade-screen/arcade-ui-components/arcade-title-tags-header.tsx delete mode 100644 src/components/arcade-screen/arcade-ui-components/arcade-wrapper-tags.tsx create mode 100644 src/components/arcade-screen/arcade-ui/constants.ts create mode 100644 src/components/arcade-screen/arcade-ui/index.tsx create mode 100644 src/components/arcade-screen/arcade-ui/msdf-font.ts create mode 100644 src/components/arcade-screen/arcade-ui/sections/featured.tsx create mode 100644 src/components/arcade-screen/arcade-ui/sections/labs-list.tsx create mode 100644 src/components/arcade-screen/arcade-ui/sections/preview.tsx create mode 100644 src/components/arcade-screen/arcade-ui/sections/title-header.tsx create mode 100644 src/components/arcade-screen/arcade-ui/sections/wrapper-tags.tsx create mode 100644 src/components/arcade-screen/arcade-ui/ui-image.tsx create mode 100644 src/components/arcade-screen/arcade-ui/ui-panel.tsx create mode 100644 src/components/arcade-screen/arcade-ui/ui-text.tsx delete mode 100644 src/components/arcade-screen/screen-ui.tsx create mode 100644 src/shaders/material-arcade-image/index.ts create mode 100644 src/shaders/material-arcade-panel/index.ts create mode 100644 src/shaders/material-arcade-text/index.ts diff --git a/docs/plans/2026-02-05-gpu-performance-120fps-design.md b/docs/plans/2026-02-05-gpu-performance-120fps-design.md new file mode 100644 index 00000000..e6a100b2 --- /dev/null +++ b/docs/plans/2026-02-05-gpu-performance-120fps-design.md @@ -0,0 +1,41 @@ +# GPU Performance Optimization for 120fps + +## Goal + +Eliminate remaining CPU-bound per-frame work by moving computations to the GPU via TSL, and clean up dead code from the WebGL-to-WebGPU migration. + +## Steps + +### Step 1: CRT Mesh timerLocal() + +**File**: `src/components/doom-js/crt-mesh.tsx` + +Replace `uTime = uniform(0)` with `timerLocal()` from TSL. Remove the per-frame `uTime.value = state.clock.getElapsedTime()` CPU write. This is safe because CRT mesh uses raw clock time (not pausable). + +### Step 2: Postprocessing dead uTime removal + +**Files**: `src/shaders/material-postprocessing/index.ts`, `src/components/postprocessing/post-processing.tsx` + +The `uTime` uniform is declared and pumped every frame but never referenced in the shader graph. Remove the declaration and the per-frame write. + +### Step 3: Arcade grid dead code + TSL distance fade + +**File**: `src/components/arcade-game/entities/grid.tsx` + +Delete `gridMaterial` (ShaderMaterial with inline GLSL — last remaining raw GLSL in the codebase) and its `useFrameCallback`. This material is dead code — never attached to rendered geometry. Add distance-based opacity fade to the actual `LineMaterial` instances via TSL NodeMaterial approach. + +### Step 4: Weather rain UV scroll to TSL + +**Files**: `src/shaders/material-global-shader/index.tsx`, `src/components/weather/index.tsx` + +Move UV scroll computation from CPU (`Matrix3.setUvTransform()` every frame) into TSL shader graph. Add `uScrollSpeed` and `uRepeat` per-material uniforms. Compute in TSL: `fract(uv().add(vec2(0, uTime.mul(uScrollSpeed))).mul(uRepeat))`. Remove the `useFrameCallback` that does `setUvTransform`. + +### Step 5: Christmas tree ornaments to TSL + +**Files**: `src/shaders/material-global-shader/index.tsx`, `src/components/christmas-tree/client.tsx` + +Move 4-phase sine-based intensity cycling and star hue rotation from CPU to TSL. Add per-material uniforms: `uPhaseOffset` (0-3 for color groups), `uBaseEmissiveIntensity`, `uIsStarMaterial`. Use `timerLocal()` (non-pausable, preserves current always-animating behavior). Remove the entire `useFrame` loop that iterates ornament meshes. + +## Verification + +After each step: `pnpm tsc --noEmit` + visual check in browser. diff --git a/docs/plans/2026-02-06-lamp-webgpu-fix-design.md b/docs/plans/2026-02-06-lamp-webgpu-fix-design.md new file mode 100644 index 00000000..db6771fd --- /dev/null +++ b/docs/plans/2026-02-06-lamp-webgpu-fix-design.md @@ -0,0 +1,121 @@ +# Fix Lamp Lighting for WebGPU + +## Problem +Two lamp-related issues after the WebGL → WebGPU migration: +1. **Glow disc** renders as solid gray disc (already fixed with AdditiveBlending) +2. **Scene lightmaps** don't change when the lamp toggles on/off, and lighting looks wrong from the start + +## Root Cause Analysis + +The lamp toggle relies on swapping lightmaps on 7 target meshes (`SM_06_01`–`SM_06_07`) via the `lightLampEnabled` uniform. The shader mixes between two lightmaps: +- Regular baked lightmap (from BakesLoader, includes lamp light) +- Lamp-off EXR lightmap (loaded in Lamp component, without lamp light) + +Static analysis confirms: +- Three.js binding system (`NodeSampledTexture.update()`) correctly detects texture reference changes at runtime +- The TSL shader mix formula is equivalent to the old GLSL if/else +- The UV2 fallback logic is identical +- The compat layer `.value` access works for both TextureNode and UniformNode + +The most likely remaining issue is **texture property timing**: BakesLoader and Lamp component modify texture properties (flipY, filter, colorSpace) AFTER loading but BEFORE assignment to TextureNodes. If the GPU texture is created before these changes take effect, the lightmap will be uploaded with wrong sampling parameters. + +## Fix Strategy + +### Fix 1: Force texture re-upload after property changes in BakesLoader + +`src/components/map/bakes.tsx` — After setting flipY/filter/colorSpace on each loaded texture, call `texture.needsUpdate = true` to ensure the GPU texture is created with the correct properties when the binding system processes the reference change. + +**Lightmaps** (line ~103-107): +```typescript +map.flipY = true +map.generateMipmaps = false +map.minFilter = NearestFilter +map.magFilter = NearestFilter +map.colorSpace = NoColorSpace +map.needsUpdate = true // <-- ADD +``` + +**AO maps** (line ~119-123): +```typescript +map.flipY = false +map.generateMipmaps = false +map.minFilter = NearestFilter +map.magFilter = NearestFilter +map.colorSpace = NoColorSpace +map.needsUpdate = true // <-- ADD +``` + +**Matcaps** (line ~134-138): +```typescript +map.flipY = false +map.generateMipmaps = false +map.minFilter = NearestFilter +map.magFilter = NearestFilter +map.colorSpace = NoColorSpace +map.needsUpdate = true // <-- ADD +``` + +**Reflexes** (line ~149-153): +```typescript +map.flipY = false +map.colorSpace = NoColorSpace +map.generateMipmaps = false +map.minFilter = NearestFilter +map.magFilter = NearestFilter +map.needsUpdate = true // <-- ADD +``` + +### Fix 2: Force texture re-upload for lamp EXR lightmap + +`src/components/lamp/index.tsx` — After modifying the EXR lightmap properties: +```typescript +useEffect(() => { + lightmap.flipY = true + lightmap.generateMipmaps = false + lightmap.minFilter = THREE.NearestFilter + lightmap.magFilter = THREE.NearestFilter + lightmap.colorSpace = THREE.NoColorSpace + lightmap.needsUpdate = true // <-- ADD +}, [lightmap]) +``` + +### Fix 3: Use proper placeholder texture instead of empty Texture + +`src/shaders/material-global-shader/index.tsx` — Replace `BLANK_TEXTURE` with a 1×1 white DataTexture that has a defined format. An empty `new Texture()` has no image data and undefined format, which could cause WebGPU backend issues during initial pipeline creation. + +```typescript +import { DataTexture, RGBAFormat, UnsignedByteType } from "three" + +const BLANK_TEXTURE = (() => { + const data = new Uint8Array([255, 255, 255, 255]) + const tex = new DataTexture(data, 1, 1, RGBAFormat, UnsignedByteType) + tex.needsUpdate = true + return tex +})() +``` + +This ensures: +- The initial texture has a valid GPU format for pipeline compilation +- The 1×1 white pixel produces `vec4(1,1,1,1)` when sampled, which is the correct multiplicative identity (no visual effect on irradiance) + +## Files Modified + +| File | Change | +|------|--------| +| `src/components/map/bakes.tsx` | Add `needsUpdate = true` after all texture property changes | +| `src/components/lamp/index.tsx` | Add `needsUpdate = true` after EXR property changes | +| `src/shaders/material-global-shader/index.tsx` | Replace empty `Texture()` with 1×1 white `DataTexture` | + +## What Stays Unchanged +- The lamp toggle logic (lightLampEnabled uniform, mix formula) +- The glow disc fix (AdditiveBlending) from earlier +- BakesLoader's texture assignment flow +- The UV2 fallback logic + +## Verification +1. `pnpm tsc --noEmit` — TypeScript +2. `pnpm build` — Build +3. Navigate to blog section → scene should have correct baked lighting +4. Pull lamp cord → scene should visibly darken (lamp OFF) +5. Pull again → scene should brighten (lamp ON) +6. Lamp glow disc should appear as soft additive glow, not solid disc diff --git a/package.json b/package.json index 3ff607fd..e366ac46 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,6 @@ "@react-three/fiber": "9.0.0-rc.6", "@react-three/offscreen": "1.0.0-rc.1", "@react-three/rapier": "^1.5.0", - "@react-three/uikit": "1.0.60", - "@react-three/uikit-default": "1.0.60", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.48.0", "@use-gesture/react": "^10.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f21122f..8c2f9e70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,12 +47,6 @@ importers: '@react-three/rapier': specifier: ^1.5.0 version: 1.5.0(@react-three/fiber@9.0.0-rc.6(@types/react@19.0.0)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(three@0.180.0))(react@19.0.1)(three@0.180.0) - '@react-three/uikit': - specifier: 1.0.60 - version: 1.0.60(@react-three/fiber@9.0.0-rc.6(@types/react@19.0.0)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(three@0.180.0))(@types/react@19.0.0)(immer@9.0.21)(react@19.0.1)(three@0.180.0)(use-sync-external-store@1.4.0(react@19.0.1)) - '@react-three/uikit-default': - specifier: 1.0.60 - version: 1.0.60(@react-three/fiber@9.0.0-rc.6(@types/react@19.0.0)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(three@0.180.0))(@types/react@19.0.0)(immer@9.0.21)(react@19.0.1)(three@0.180.0)(use-sync-external-store@1.4.0(react@19.0.1)) '@supabase/ssr': specifier: ^0.5.2 version: 0.5.2(@supabase/supabase-js@2.48.1) @@ -1201,23 +1195,6 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@pmndrs/msdfonts@1.0.60': - resolution: {integrity: sha512-ZSYH/Y5XRgsnXCRpYkM6KmlM47m06CD1K1VjdBmoRsaXki7tqurP83QemPrV/VfQXfCqC49lQIYCabOCCL3HwQ==} - - '@pmndrs/uikit-default@1.0.60': - resolution: {integrity: sha512-0rX1HGOdwAGRuT4OgwnGWGKD1cka8HUh4j53/cNvlQf2hCjlaQTKkD3cxF9XPg9oNMqFIxAEUs5PJNdJ4UweHA==} - - '@pmndrs/uikit-lucide@1.0.60': - resolution: {integrity: sha512-pqibLiw7LPp70+TkRxLBkwSNyUuvhDDZLVJE7mMxFMcXkqYz8blSVmUKOTCx1XyxefrNKLZM6RJ6s7Bh+W5FhA==} - - '@pmndrs/uikit-pub-sub@1.0.60': - resolution: {integrity: sha512-rciKs4iRsAYfGCuWS2b3uYGd2hoJXFso0rOcP20iHxu2Lk1XnYamPgtCmJZyhnkNiqJm+6Ms2MzGad+jcZ0yKw==} - - '@pmndrs/uikit@1.0.60': - resolution: {integrity: sha512-yKBxMIjT3JEHz6hcQxieqklCjBrAEq3oVvBcccCSvmkXmkBYJ+QLlBWDQTxcIQWxOQVBoiehgUB2G6mTNIuceQ==} - peerDependencies: - three: '>=0.162' - '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -1230,9 +1207,6 @@ packages: resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} engines: {node: '>=12'} - '@preact/signals-core@1.8.0': - resolution: {integrity: sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==} - '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} @@ -1713,15 +1687,6 @@ packages: react: '>=18.0.0' three: '>=0.139.0' - '@react-three/uikit-default@1.0.60': - resolution: {integrity: sha512-+R5A+trrppYumOXOWnpAcnspalsepbgx2YKHVlK5/zeS971vLCyjs8I6hAIy0esF128IZY9xT+mC988J5jhRBA==} - - '@react-three/uikit@1.0.60': - resolution: {integrity: sha512-SppRlp3JQ2kOCRN9gnJ+aEGQccKpU7SwLNzsRyT3m2FdvoUR+me8HINEB9W0fd5dp6O0jV7vzngnL1g0Cxs6oA==} - peerDependencies: - '@react-three/fiber': '>=8' - react: '>=18' - '@reduxjs/toolkit@1.9.7': resolution: {integrity: sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==} peerDependencies: @@ -5181,9 +5146,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoga-layout@3.2.1: - resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} - zod@3.22.1: resolution: {integrity: sha512-+qUhAMl414+Elh+fRNtpU+byrwjDFOS1N7NioLY+tSlcADTx4TkCUua/hxJvxwDXcV4397/nZ420jy4n4+3WUg==} @@ -5229,24 +5191,6 @@ packages: use-sync-external-store: optional: true - zustand@5.0.9: - resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: - optional: true - zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6182,33 +6126,6 @@ snapshots: '@pkgr/core@0.1.1': {} - '@pmndrs/msdfonts@1.0.60': {} - - '@pmndrs/uikit-default@1.0.60(three@0.180.0)': - dependencies: - '@pmndrs/uikit': 1.0.60(three@0.180.0) - '@pmndrs/uikit-lucide': 1.0.60(three@0.180.0) - transitivePeerDependencies: - - three - - '@pmndrs/uikit-lucide@1.0.60(three@0.180.0)': - dependencies: - '@pmndrs/uikit': 1.0.60(three@0.180.0) - transitivePeerDependencies: - - three - - '@pmndrs/uikit-pub-sub@1.0.60': - dependencies: - '@preact/signals-core': 1.8.0 - - '@pmndrs/uikit@1.0.60(three@0.180.0)': - dependencies: - '@pmndrs/msdfonts': 1.0.60 - '@pmndrs/uikit-pub-sub': 1.0.60 - '@preact/signals-core': 1.8.0 - three: 0.180.0 - yoga-layout: 3.2.1 - '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -6221,8 +6138,6 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@preact/signals-core@1.8.0': {} - '@radix-ui/primitive@1.1.1': {} '@radix-ui/react-accordion@1.2.2(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)': @@ -6711,32 +6626,6 @@ snapshots: three: 0.180.0 three-stdlib: 2.36.0(three@0.180.0) - '@react-three/uikit-default@1.0.60(@react-three/fiber@9.0.0-rc.6(@types/react@19.0.0)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(three@0.180.0))(@types/react@19.0.0)(immer@9.0.21)(react@19.0.1)(three@0.180.0)(use-sync-external-store@1.4.0(react@19.0.1))': - dependencies: - '@pmndrs/uikit-default': 1.0.60(three@0.180.0) - '@react-three/uikit': 1.0.60(@react-three/fiber@9.0.0-rc.6(@types/react@19.0.0)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(three@0.180.0))(@types/react@19.0.0)(immer@9.0.21)(react@19.0.1)(three@0.180.0)(use-sync-external-store@1.4.0(react@19.0.1)) - transitivePeerDependencies: - - '@react-three/fiber' - - '@types/react' - - immer - - react - - three - - use-sync-external-store - - '@react-three/uikit@1.0.60(@react-three/fiber@9.0.0-rc.6(@types/react@19.0.0)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(three@0.180.0))(@types/react@19.0.0)(immer@9.0.21)(react@19.0.1)(three@0.180.0)(use-sync-external-store@1.4.0(react@19.0.1))': - dependencies: - '@pmndrs/uikit': 1.0.60(three@0.180.0) - '@preact/signals-core': 1.8.0 - '@react-three/fiber': 9.0.0-rc.6(@types/react@19.0.0)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(three@0.180.0) - react: 19.0.1 - suspend-react: 0.1.3(react@19.0.1) - zustand: 5.0.9(@types/react@19.0.0)(immer@9.0.21)(react@19.0.1)(use-sync-external-store@1.4.0(react@19.0.1)) - transitivePeerDependencies: - - '@types/react' - - immer - - three - - use-sync-external-store - '@reduxjs/toolkit@1.9.7(react-redux@8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@4.2.1))(react@19.0.1)': dependencies: immer: 9.0.21 @@ -10612,8 +10501,6 @@ snapshots: yocto-queue@0.1.0: {} - yoga-layout@3.2.1: {} - zod@3.22.1: {} zustand@3.7.2(react@19.0.1): @@ -10635,11 +10522,4 @@ snapshots: react: 19.0.1 use-sync-external-store: 1.4.0(react@19.0.1) - zustand@5.0.9(@types/react@19.0.0)(immer@9.0.21)(react@19.0.1)(use-sync-external-store@1.4.0(react@19.0.1)): - optionalDependencies: - '@types/react': 19.0.0 - immer: 9.0.21 - react: 19.0.1 - use-sync-external-store: 1.4.0(react@19.0.1) - zwitch@2.0.4: {} diff --git a/src/components/arcade-game/index.tsx b/src/components/arcade-game/index.tsx index 758073c4..2008aba9 100644 --- a/src/components/arcade-game/index.tsx +++ b/src/components/arcade-game/index.tsx @@ -1,17 +1,18 @@ import { useTexture } from "@react-three/drei" -import { Container, DefaultProperties, Text } from "@react-three/uikit" import { useEffect, useRef, useState } from "react" -import { COLORS_THEME } from "@/components/arcade-screen/screen-ui" + +import { + type FontData, + loadMsdfFont, + MsdfFontContext +} from "@/components/arcade-screen/arcade-ui/msdf-font" +import { UIText } from "@/components/arcade-screen/arcade-ui/ui-text" import { useAssets } from "@/components/assets-provider" import { useCurrentScene } from "@/hooks/use-current-scene" import { useFrameCallback } from "@/hooks/use-pausable-time" import { useArcadeStore } from "@/store/arcade-store" -import ffflauta from "../../../public/fonts/ffflauta.json" import { useGame } from "./lib/use-game" - -// Convert font object to JSON data URL for react-three/uikit -const ffflautaUrl = `data:application/json;base64,${btoa(JSON.stringify(ffflauta))}` import { NPCs } from "./npc" import { useNpc } from "./npc/use-npc" import { Player } from "./player" @@ -24,6 +25,10 @@ interface arcadeGameProps { screenUniforms: { uIsGameRunning: { value: number } } } +// Scale factor to convert MSDF virtual units to game world units +// Approximates uikit's default pixel size for the game overlay +const GAME_TEXT_SCALE = 0.005 + export const ArcadeGame = ({ visible, screenUniforms }: arcadeGameProps) => { const setSpeed = useRoad((s) => s.setSpeed) const speedRef = useRoad((s) => s.speedRef) @@ -39,6 +44,11 @@ export const ArcadeGame = ({ visible, screenUniforms }: arcadeGameProps) => { const introScreenTexture = useTexture(arcade.introScreen) const clearNpcs = useNpc((s) => s.clearNpcs) const scene = useCurrentScene() + const [gameFont, setGameFont] = useState(null) + + useEffect(() => { + loadMsdfFont("/fonts/ffflauta.json").then(setGameFont) + }, []) useFrameCallback((_, delta) => { if (gameStarted && !gameOver) { @@ -145,72 +155,46 @@ export const ArcadeGame = ({ visible, screenUniforms }: arcadeGameProps) => { - - + {gameFont && ( + + {/* Score display — positioned near top of game view */} {(gameStarted || gameOver) && ( - - SCORE: {`${scoreDisplay}`} - + + + + + )} - - {gameStarted && gameOver && ( - - - - )} - {gameStarted && gameOver && ( - - + )} + {gameStarted && gameOver && ( + - PRESS [ESC] TO EXIT - - - )} - - - + color="#000000" + anchorX="center" + position={[0, -25, 0]} + /> + )} + + + + )} diff --git a/src/components/arcade-screen/arcade-ui-components/arcade-featured.tsx b/src/components/arcade-screen/arcade-ui-components/arcade-featured.tsx deleted file mode 100644 index bae2ed44..00000000 --- a/src/components/arcade-screen/arcade-ui-components/arcade-featured.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { Container, Image, Text } from "@react-three/uikit" -import { Separator } from "@react-three/uikit-default" -import React, { useCallback, useState } from "react" - -import { useAssets } from "@/components/assets-provider" -import { useKeyPress } from "@/hooks/use-key-press" -import { useCursor } from "@/hooks/use-mouse" -import { useArcadeStore } from "@/store/arcade-store" - -import { COLORS_THEME } from "../screen-ui" - -export const ArcadeFeatured = () => { - const { arcade } = useAssets() - - const [hoveredSection, setHoveredSection] = useState({ - chronicles: false, - looper: false - }) - - const isInLabTab = useArcadeStore((state) => state.isInLabTab) - const labTabIndex = useArcadeStore((state) => state.labTabIndex) - const experiments = useArcadeStore((state) => state.labTabs) - const setCursor = useCursor() - const isChroniclesSelected = - isInLabTab && labTabIndex === experiments.length - 2 - const isLooperSelected = isInLabTab && labTabIndex === experiments.length - 1 - - const handleChroniclesClick = useCallback(() => { - window.open("https://chronicles.basement.studio", "_blank") - }, []) - - useKeyPress( - "Enter", - useCallback(() => { - if (isChroniclesSelected) { - handleChroniclesClick() - } - }, [isChroniclesSelected, handleChroniclesClick]) - ) - - const handleLooperClick = useCallback(() => { - //TODO: ADD LOOPER - }, []) - - useKeyPress( - "Enter", - useCallback(() => { - if (isLooperSelected) { - handleLooperClick() - } - }, [isLooperSelected, handleLooperClick]) - ) - - return ( - - - { - handleChroniclesClick() - }} - onHoverChange={(hover) => { - if (hover || isChroniclesSelected) { - setCursor("alias") - setHoveredSection((prev) => ({ ...prev, chronicles: true })) - } else { - setCursor("default") - setHoveredSection((prev) => ({ ...prev, chronicles: false })) - } - }} - > - - - PLAY BASEMENT CHRONICLES - - - - - - { - if (hover || isLooperSelected) { - setCursor("not-allowed") - setHoveredSection((prev) => ({ ...prev, looper: true })) - } else { - setCursor("default") - setHoveredSection((prev) => ({ ...prev, looper: false })) - } - }} - > - - - LOOPER (COOMING SOON) - - - - - - - ) -} diff --git a/src/components/arcade-screen/arcade-ui-components/arcade-labs-list.tsx b/src/components/arcade-screen/arcade-ui-components/arcade-labs-list.tsx deleted file mode 100644 index bf7c9d29..00000000 --- a/src/components/arcade-screen/arcade-ui-components/arcade-labs-list.tsx +++ /dev/null @@ -1,331 +0,0 @@ -import { Container, Text } from "@react-three/uikit" -import { useCallback, useEffect, useRef, useState } from "react" - -import { useKeyPress } from "@/hooks/use-key-press" -import { useCursor } from "@/hooks/use-mouse" -import { useArcadeStore } from "@/store/arcade-store" - -import { COLORS_THEME } from "../screen-ui" - -interface ArcadeLabsListProps { - experiments: any[] - selectedExperiment: any - setSelectedExperiment: (experiment: any) => void -} - -export const ArcadeLabsList = ({ - experiments, - selectedExperiment, - setSelectedExperiment -}: ArcadeLabsListProps) => { - const labTabIndex = useArcadeStore((state) => state.labTabIndex) - const isInLabTab = useArcadeStore((state) => state.isInLabTab) - const scrollContainerRef = useRef(null) - const [sourceHoverStates, setSourceHoverStates] = useState( - new Array(experiments.length).fill(false) - ) - const setCursor = useCursor() - const [mouseHoveredExperiment, setMouseHoveredExperiment] = - useState(null) - const [hasMouseInteracted, setHasMouseInteracted] = useState(false) - const isSourceButtonSelected = useArcadeStore( - (state) => state.isSourceButtonSelected - ) - const isInGame = useArcadeStore((state) => state.isInGame) - - const handleExperimentClick = useCallback((data: any) => { - window.open(`https://lab.basement.studio/experiments/${data.url}`, "_blank") - }, []) - - useKeyPress( - "Enter", - useCallback(() => { - if (isInLabTab && !isInGame) { - if (mouseHoveredExperiment) { - handleExperimentClick(mouseHoveredExperiment) - } else if (labTabIndex > 0 && labTabIndex <= experiments.length) { - handleExperimentClick(experiments[labTabIndex - 1]) - } - } - }, [ - isInLabTab, - labTabIndex, - experiments, - handleExperimentClick, - mouseHoveredExperiment, - isInGame - ]) - ) - - useEffect(() => { - if (isInLabTab && labTabIndex > 0 && labTabIndex <= experiments.length) { - setSelectedExperiment(experiments[labTabIndex - 1]) - setHasMouseInteracted(false) - } else { - setSelectedExperiment(null) - } - }, [labTabIndex, isInLabTab, experiments, setSelectedExperiment]) - - useEffect(() => { - if (scrollContainerRef.current) { - // reset scroll - if (labTabIndex <= 6) { - if (scrollContainerRef.current.scrollPosition.value) { - scrollContainerRef.current.scrollPosition.value = [0, 0] - } else { - scrollContainerRef.current.scrollPosition.v = [0, 0] - } - scrollContainerRef.current.forceUpdate?.() - return - } - - if (labTabIndex >= 7) { - const scrollStep = 24 - const maxScroll = 277 - const scrollOffset = (labTabIndex - 7) * scrollStep - - const newScroll = - scrollOffset <= 0 ? 0 : Math.min(scrollOffset, maxScroll) - - if (scrollContainerRef.current.scrollPosition.value) { - scrollContainerRef.current.scrollPosition.value = [0, newScroll] - } else { - scrollContainerRef.current.scrollPosition.v = [0, newScroll] - } - - scrollContainerRef.current.forceUpdate?.() - } - } - }, [labTabIndex]) - - return ( - { - if (!hover) { - setSelectedExperiment(null) - } - }} - > - {experiments && - experiments.map((data, idx) => { - const isHovered = - (!hasMouseInteracted && - !mouseHoveredExperiment && - isInLabTab && - labTabIndex === idx + 1 && - !isSourceButtonSelected) || - (selectedExperiment?._title === data._title && - !isSourceButtonSelected && - labTabIndex === idx + 1) || - mouseHoveredExperiment?._title === data._title - - const isSourceHovered = - (!hasMouseInteracted && - !mouseHoveredExperiment && - isInLabTab && - labTabIndex === idx + 1 && - isSourceButtonSelected) || - sourceHoverStates[idx] - - return ( - { - handleExperimentClick(data) - }} - onHoverChange={(hover) => { - if (hover) { - setCursor("alias") - setSelectedExperiment(data) - setMouseHoveredExperiment(data) - setHasMouseInteracted(true) - } else { - setCursor("default") - setMouseHoveredExperiment(null) - } - }} - > - - - {data._title.toUpperCase()} - - - { - window.open( - `https://github.com/basementstudio/basement-laboratory/tree/main/src/experiments/${data.url}`, - "_blank" - ) - }} - onHoverChange={(hover) => { - if (hover) { - setCursor("alias") - setSelectedExperiment(data) - setSourceHoverStates((prev) => { - const newStates = [...prev] - newStates[idx] = true - return newStates - }) - } else { - setCursor("default") - setSourceHoverStates((prev) => { - const newStates = [...prev] - newStates[idx] = false - return newStates - }) - if (!mouseHoveredExperiment) { - setSelectedExperiment(null) - } - } - }} - zIndexOffset={12} - > - - SOURCE - - - - - ) - })} - 0} - setSelectedExperiment={setSelectedExperiment} - /> - - ) -} - -const ViewMore = ({ - isLoaded, - setSelectedExperiment -}: { - isLoaded: boolean - setSelectedExperiment: (experiment: any) => void -}) => { - const [isViewMoreHovered, setIsViewMoreHovered] = useState(false) - const labTabIndex = useArcadeStore((state) => state.labTabIndex) - const isInLabTab = useArcadeStore((state) => state.isInLabTab) - const experiments = useArcadeStore((state) => state.labTabs) - const setCursor = useCursor() - const handleViewMoreClick = useCallback(() => { - window.open("https://lab.basement.studio/", "_blank") - }, []) - - const isSelected = isInLabTab && labTabIndex === experiments.length - 3 - - useKeyPress( - "Enter", - useCallback(() => { - if (isSelected) { - handleViewMoreClick() - } - }, [isSelected, handleViewMoreClick]) - ) - - if (!isLoaded) return null - return ( - { - if (hover) { - setSelectedExperiment(null) - setIsViewMoreHovered(true) - setCursor("alias") - } else { - setSelectedExperiment(null) - setIsViewMoreHovered(false) - setCursor("default") - } - }} - > - - VIEW MORE - - - ) -} diff --git a/src/components/arcade-screen/arcade-ui-components/arcade-preview.tsx b/src/components/arcade-screen/arcade-ui-components/arcade-preview.tsx deleted file mode 100644 index 51876ad4..00000000 --- a/src/components/arcade-screen/arcade-ui-components/arcade-preview.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Container, Image, Text } from "@react-three/uikit" -import React from "react" - -import { useAssets } from "@/components/assets-provider" - -import { COLORS_THEME } from "../screen-ui" - -interface ArcadePreviewProps { - selectedExperiment: any -} - -export const ArcadePreview = ({ selectedExperiment }: ArcadePreviewProps) => { - const { arcade } = useAssets() - return ( - - - - - - {(selectedExperiment?.description?.toUpperCase() || "").slice(0, 100) + - (selectedExperiment?.description?.length > 100 ? "..." : "")} - - - ) -} diff --git a/src/components/arcade-screen/arcade-ui-components/arcade-title-tags-header.tsx b/src/components/arcade-screen/arcade-ui-components/arcade-title-tags-header.tsx deleted file mode 100644 index 37e20faa..00000000 --- a/src/components/arcade-screen/arcade-ui-components/arcade-title-tags-header.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Container, Text } from "@react-three/uikit" - -import { COLORS_THEME } from "../screen-ui" - -export const ArcadeTitleTagsHeader = () => { - return ( - - - - EXPERIMENTS - - - PREVIEW - - - ) -} diff --git a/src/components/arcade-screen/arcade-ui-components/arcade-wrapper-tags.tsx b/src/components/arcade-screen/arcade-ui-components/arcade-wrapper-tags.tsx deleted file mode 100644 index 1b44e478..00000000 --- a/src/components/arcade-screen/arcade-ui-components/arcade-wrapper-tags.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Container, Text } from "@react-three/uikit" -import { useCallback, useEffect, useState } from "react" - -import { useNavigationStore } from "@/components/navigation-handler/navigation-store" -import { useHandleNavigation } from "@/hooks/use-handle-navigation" -import { useKeyPress } from "@/hooks/use-key-press" -import { useCursor } from "@/hooks/use-mouse" -import { useArcadeStore } from "@/store/arcade-store" - -import { COLORS_THEME } from "../screen-ui" - -export const ArcadeWrapperTags = () => { - const { handleNavigation } = useHandleNavigation() - const isInLabTab = useArcadeStore((state) => state.isInLabTab) - const labTabIndex = useArcadeStore((state) => state.labTabIndex) - const setCurrentTabIndex = useNavigationStore( - (state) => state.setCurrentTabIndex - ) - const setCursor = useCursor() - const handleClose = useCallback(() => { - handleNavigation("/") - setCurrentTabIndex(-1) - }, [handleNavigation, setCurrentTabIndex]) - - const [hoverClose, setHoverClose] = useState(false) - - useEffect(() => { - if (isInLabTab && labTabIndex === 0) { - setHoverClose(true) - } else { - setHoverClose(false) - } - }, [isInLabTab, labTabIndex]) - - useKeyPress( - "Enter", - useCallback(() => { - if (isInLabTab && labTabIndex === 0) { - handleClose() - } - }, [isInLabTab, labTabIndex, handleClose]) - ) - - return ( - <> - handleClose()} - onHoverChange={(hover) => { - if (hover) { - setCursor("pointer") - setHoverClose(true) - } else { - setCursor("default") - setHoverClose(false) - } - }} - > - - CLOSE [ESC] - - - - LABS V1.0 - - - ) -} diff --git a/src/components/arcade-screen/arcade-ui/constants.ts b/src/components/arcade-screen/arcade-ui/constants.ts new file mode 100644 index 00000000..8a27dc84 --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/constants.ts @@ -0,0 +1,58 @@ +// Color theme +export const COLORS = { + primary: "#FF4D00", + black: "#000000" +} as const + +// Outer container (matches uikit Container 590x390 with paddingX=18, paddingY=24) +export const UI_WIDTH = 590 +export const UI_HEIGHT = 390 +export const PADDING_X = 18 +export const PADDING_Y = 24 + +// Inner frame (inside outer padding, with border) +export const INNER_WIDTH = UI_WIDTH - PADDING_X * 2 // 554 +export const INNER_HEIGHT = UI_HEIGHT - PADDING_Y * 2 // 342 +export const BORDER_WIDTH = 1.5 +export const BORDER_RADIUS = 10 + +// Inner frame padding +export const INNER_PADDING = 10 + +// Content area (inside inner frame padding) +export const CONTENT_WIDTH = INNER_WIDTH - INNER_PADDING * 2 // 534 +export const CONTENT_HEIGHT = INNER_HEIGHT - INNER_PADDING * 2 // 322 + +// Section headers +export const HEADER_HEIGHT = 10 +export const HEADER_MARGIN_TOP = 8 + +// Content split (60/40 with gap) +export const CONTENT_GAP = 10 +export const LEFT_RATIO = 0.6 +export const RIGHT_RATIO = 0.4 +export const LEFT_WIDTH = CONTENT_WIDTH * LEFT_RATIO - CONTENT_GAP / 2 // ~317 +export const RIGHT_WIDTH = CONTENT_WIDTH * RIGHT_RATIO - CONTENT_GAP / 2 // ~207 + +// List items +export const LIST_ITEM_HEIGHT = 24 + +// Featured section +export const FEATURED_HEIGHT = 100 + +// Font sizes (matching uikit config) +export const FONT_SIZE_DEFAULT = 13 +export const FONT_SIZE_SMALL = 10 +export const FONT_SIZE_TINY = 8 +export const FONT_SIZE_HEADER = 11 +export const FONT_SIZE_LABEL = 9 + +// Camera setup for coordinate mapping +// PerspectiveCamera at z=4.01 with fov=50 (Three.js default) +export const CAMERA_Z = 4.01 +export const CAMERA_FOV = 50 +// Visible height at z=0: 2 * z * tan(fov/2) +export const VISIBLE_HEIGHT = + 2 * CAMERA_Z * Math.tan((CAMERA_FOV / 2) * (Math.PI / 180)) +// Scale: virtual units → world units +export const WORLD_SCALE = VISIBLE_HEIGHT / UI_HEIGHT diff --git a/src/components/arcade-screen/arcade-ui/index.tsx b/src/components/arcade-screen/arcade-ui/index.tsx new file mode 100644 index 00000000..a721d299 --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/index.tsx @@ -0,0 +1,208 @@ +import { useEffect, useRef, useState } from "react" + +import { fetchLaboratory } from "@/actions/laboratory-fetch" +import { useArcadeStore } from "@/store/arcade-store" + +import { + BORDER_RADIUS, + BORDER_WIDTH, + COLORS, + CONTENT_GAP, + CONTENT_WIDTH, + FEATURED_HEIGHT, + HEADER_HEIGHT, + HEADER_MARGIN_TOP, + INNER_HEIGHT, + INNER_PADDING, + INNER_WIDTH, + LEFT_WIDTH, + RIGHT_WIDTH, + UI_HEIGHT, + UI_WIDTH, + WORLD_SCALE +} from "./constants" +import { type FontData, loadMsdfFont, MsdfFontContext } from "./msdf-font" +import { Featured } from "./sections/featured" +import { LabsList } from "./sections/labs-list" +import { Preview } from "./sections/preview" +import { TitleHeader } from "./sections/title-header" +import { WrapperTags } from "./sections/wrapper-tags" +import { UIPanel } from "./ui-panel" + +export interface LabTab { + id: string + type: "button" | "experiment" | "featured" + title: string + url?: string + isClickable: boolean +} + +export const createLabTabs = (experiments: any[]): LabTab[] => { + return [ + { + id: "close", + type: "button", + title: "CLOSE [ESC]", + isClickable: true + }, + ...experiments.map((exp) => ({ + id: `experiment-${exp._title}`, + type: "experiment" as const, + title: exp._title.toUpperCase(), + url: `https://lab.basement.studio/experiments/${exp.url}`, + isClickable: true + })), + { + id: "view-more", + type: "button", + title: "VIEW MORE", + url: "https://lab.basement.studio/", + isClickable: true + }, + { + id: "chronicles", + type: "featured", + title: "CHRONICLES", + url: "https://chronicles.basement.studio", + isClickable: true + }, + { + id: "looper", + type: "featured", + title: "LOOPER (COMING SOON)", + isClickable: false + } + ] +} + +interface ArcadeUIProps { + visible: boolean +} + +export const ArcadeUI = ({ visible }: ArcadeUIProps) => { + const [font, setFont] = useState(null) + const [experiments, setExperiments] = useState([]) + const [selectedExperiment, setSelectedExperiment] = useState(null) + const loadedRef = useRef(false) + + // Load MSDF font + useEffect(() => { + loadMsdfFont("/fonts/ffflauta.json").then(setFont) + }, []) + + // Fetch experiments when visible + useEffect(() => { + if (!visible || loadedRef.current) return + loadedRef.current = true + + fetchLaboratory().then((data) => { + const exps = data.projectList.items.map((item: any) => ({ + _title: item._title, + url: item.url, + cover: item.cover, + description: item.description as string | null + })) + setExperiments(exps) + + const labTabs = createLabTabs(exps) + useArcadeStore.getState().setLabTabs(labTabs) + }) + }, [visible]) + + if (!font) return null + + // Coordinate chain: camera [0,0,PI] flips X+Y. Screen shader (uFlip=0) flips + // the render target Y for WebGPU convention. scale.x = -W un-mirrors X (matching + // old ScreenUI scale={[-1,1,1]}). scale.y = +W leaves camera Y-flip intact so the + // shader's second flip produces a correct final image. + + // Inner frame position: centered inside the outer container + // Outer container is UI_WIDTH x UI_HEIGHT, inner is INNER_WIDTH x INNER_HEIGHT + // Inner starts at (PADDING_X, PADDING_Y) from outer top-left + + // Content starts inside inner frame, after inner padding + const contentStartY = INNER_PADDING + HEADER_MARGIN_TOP + HEADER_HEIGHT + INNER_PADDING + + // Available height for content (list + featured) + const listAreaHeight = + INNER_HEIGHT - contentStartY - INNER_PADDING - FEATURED_HEIGHT - INNER_PADDING + + return ( + + + {/* Outer black background (no border) */} + + + {/* Inner bordered frame */} + + {/* Content is positioned relative to inner frame top-left */} + + {/* Wrapper tags (absolute positioned) */} + + + {/* Title headers + separator */} + + + {/* Content area */} + + {/* Left: Experiments list (60%) */} + + + {/* Right: Preview (40%) */} + + + + + + {/* Featured section at bottom */} + + + + + + + ) +} + +export default ArcadeUI diff --git a/src/components/arcade-screen/arcade-ui/msdf-font.ts b/src/components/arcade-screen/arcade-ui/msdf-font.ts new file mode 100644 index 00000000..2caf812c --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/msdf-font.ts @@ -0,0 +1,175 @@ +import { + createContext, + useContext +} from "react" +import { + BufferAttribute, + BufferGeometry, + LinearFilter, + Texture, + TextureLoader +} from "three" + +// --- Types --- + +export interface GlyphMetrics { + id: number + char: string + x: number + y: number + width: number + height: number + xoffset: number + yoffset: number + xadvance: number +} + +export interface FontData { + atlas: Texture + glyphs: Map + lineHeight: number + base: number + scaleW: number + scaleH: number + size: number + distanceRange: number +} + +// --- Font Loading --- + +export async function loadMsdfFont(jsonUrl: string): Promise { + const res = await fetch(jsonUrl) + const json = await res.json() + + // Decode base64 PNG atlas from pages[0] + const atlasDataUrl = json.pages[0] + const atlas = await new TextureLoader().loadAsync(atlasDataUrl) + atlas.flipY = false + atlas.minFilter = LinearFilter + atlas.magFilter = LinearFilter + atlas.needsUpdate = true + + // Build glyph lookup + const glyphs = new Map() + for (const c of json.chars) { + glyphs.set(c.id, { + id: c.id, + char: String.fromCharCode(c.id), + x: c.x, + y: c.y, + width: c.width, + height: c.height, + xoffset: c.xoffset, + yoffset: c.yoffset, + xadvance: c.xadvance + }) + } + + return { + atlas, + glyphs, + lineHeight: json.common.lineHeight, + base: json.common.base, + scaleW: json.common.scaleW, + scaleH: json.common.scaleH, + size: json.info.size, + distanceRange: json.distanceField?.distanceRange ?? 4 + } +} + +// --- Geometry Builder --- + +/** + * Build a merged BufferGeometry for all glyphs in the text string. + * Each glyph is a quad (2 triangles, 6 vertices). + * Origin is at the top-left of the text. + */ +export function buildTextGeometry( + text: string, + fontSize: number, + font: FontData +): BufferGeometry { + const scale = fontSize / font.size + const positions: number[] = [] + const uvs: number[] = [] + + let cursorX = 0 + + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + const glyph = font.glyphs.get(code) + if (!glyph) { + // Space or missing char — just advance + cursorX += (font.glyphs.get(32)?.xadvance ?? font.size * 0.5) * scale + continue + } + + if (glyph.width === 0 || glyph.height === 0) { + cursorX += glyph.xadvance * scale + continue + } + + // Quad corners in local space (Y goes down from top) + const x0 = cursorX + glyph.xoffset * scale + const y0 = -(glyph.yoffset * scale) + const x1 = x0 + glyph.width * scale + const y1 = y0 - glyph.height * scale + + // UV coordinates into the atlas + const u0 = glyph.x / font.scaleW + const v0 = glyph.y / font.scaleH + const u1 = (glyph.x + glyph.width) / font.scaleW + const v1 = (glyph.y + glyph.height) / font.scaleH + + // Two triangles (CCW winding) + // Triangle 1: top-left, bottom-left, top-right + positions.push(x0, y0, 0, x0, y1, 0, x1, y0, 0) + uvs.push(u0, v0, u0, v1, u1, v0) + + // Triangle 2: top-right, bottom-left, bottom-right + positions.push(x1, y0, 0, x0, y1, 0, x1, y1, 0) + uvs.push(u1, v0, u0, v1, u1, v1) + + cursorX += glyph.xadvance * scale + } + + const geometry = new BufferGeometry() + geometry.setAttribute( + "position", + new BufferAttribute(new Float32Array(positions), 3) + ) + geometry.setAttribute( + "uv", + new BufferAttribute(new Float32Array(uvs), 2) + ) + + return geometry +} + +/** + * Measure the width of a text string in virtual units. + */ +export function measureText( + text: string, + fontSize: number, + font: FontData +): number { + const scale = fontSize / font.size + let width = 0 + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + const glyph = font.glyphs.get(code) + width += (glyph?.xadvance ?? font.size * 0.5) * scale + } + return width +} + +// --- React Context --- + +export const MsdfFontContext = createContext(null) + +export function useMsdfFont(): FontData { + const font = useContext(MsdfFontContext) + if (!font) throw new Error("useMsdfFont must be used within MsdfFontContext.Provider") + return font +} diff --git a/src/components/arcade-screen/arcade-ui/sections/featured.tsx b/src/components/arcade-screen/arcade-ui/sections/featured.tsx new file mode 100644 index 00000000..d1d1b92d --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/sections/featured.tsx @@ -0,0 +1,160 @@ +import { Suspense, useCallback, useState } from "react" + +import { useAssets } from "@/components/assets-provider" +import { useKeyPress } from "@/hooks/use-key-press" +import { useCursor } from "@/hooks/use-mouse" +import { useArcadeStore } from "@/store/arcade-store" + +import { COLORS, FONT_SIZE_TINY } from "../constants" +import { UIImage } from "../ui-image" +import { UIPanel } from "../ui-panel" +import { UIText } from "../ui-text" + +interface FeaturedProps { + width: number + height: number + experiments: any[] +} + +export const Featured = ({ width, height, experiments }: FeaturedProps) => { + const { arcade } = useAssets() + const setCursor = useCursor() + const isInLabTab = useArcadeStore((state) => state.isInLabTab) + const labTabIndex = useArcadeStore((state) => state.labTabIndex) + const labTabs = useArcadeStore((state) => state.labTabs) + + const [hoveredSection, setHoveredSection] = useState({ + chronicles: false, + looper: false + }) + + const isChroniclesSelected = isInLabTab && labTabIndex === labTabs.length - 2 + const isLooperSelected = isInLabTab && labTabIndex === labTabs.length - 1 + + const handleChroniclesClick = useCallback(() => { + window.open("https://chronicles.basement.studio", "_blank") + }, []) + + useKeyPress( + "Enter", + useCallback(() => { + if (isChroniclesSelected) handleChroniclesClick() + }, [isChroniclesSelected, handleChroniclesClick]) + ) + + const halfWidth = (width - 1) / 2 // -1 for separator + const chroniclesHighlighted = + hoveredSection.chronicles || isChroniclesSelected + const looperHighlighted = hoveredSection.looper || isLooperSelected + + return ( + + {/* Outer border */} + + + {/* Chronicles section (left half) */} + + {/* Background image */} + + + + + {/* Label overlay (centered) */} + + handleChroniclesClick()} + onPointerOver={(e) => { + e.stopPropagation() + setCursor("alias") + setHoveredSection((prev) => ({ ...prev, chronicles: true })) + }} + onPointerOut={(e) => { + e.stopPropagation() + setCursor("default") + if (!isChroniclesSelected) + setHoveredSection((prev) => ({ ...prev, chronicles: false })) + }} + > + + + + + + {/* Vertical separator */} + + + {/* Looper section (right half) */} + + {/* Background image */} + + + + + {/* Label overlay (centered) */} + + { + e.stopPropagation() + setCursor("not-allowed") + setHoveredSection((prev) => ({ ...prev, looper: true })) + }} + onPointerOut={(e) => { + e.stopPropagation() + setCursor("default") + if (!isLooperSelected) + setHoveredSection((prev) => ({ ...prev, looper: false })) + }} + > + + + + + + ) +} diff --git a/src/components/arcade-screen/arcade-ui/sections/labs-list.tsx b/src/components/arcade-screen/arcade-ui/sections/labs-list.tsx new file mode 100644 index 00000000..339455e4 --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/sections/labs-list.tsx @@ -0,0 +1,417 @@ +import { memo, useCallback, useEffect, useMemo, useState } from "react" + +import { useKeyPress } from "@/hooks/use-key-press" +import { useCursor } from "@/hooks/use-mouse" +import { useArcadeStore } from "@/store/arcade-store" + +import { COLORS, FONT_SIZE_SMALL, LIST_ITEM_HEIGHT } from "../constants" +import { UIPanel } from "../ui-panel" +import { UIText } from "../ui-text" + +interface LabsListProps { + experiments: any[] + selectedExperiment: any + setSelectedExperiment: (experiment: any) => void + width: number + height: number +} + +export const LabsList = ({ + experiments, + selectedExperiment, + setSelectedExperiment, + width, + height +}: LabsListProps) => { + const labTabIndex = useArcadeStore((state) => state.labTabIndex) + const isInLabTab = useArcadeStore((state) => state.isInLabTab) + const isSourceButtonSelected = useArcadeStore( + (state) => state.isSourceButtonSelected + ) + const isInGame = useArcadeStore((state) => state.isInGame) + const setCursor = useCursor() + + const [mouseHoveredExperiment, setMouseHoveredExperiment] = + useState(null) + const [hasMouseInteracted, setHasMouseInteracted] = useState(false) + const [sourceHoverStates, setSourceHoverStates] = useState([]) + + // Reset source hover states when experiments change + useEffect(() => { + setSourceHoverStates(new Array(experiments.length).fill(false)) + }, [experiments.length]) + + // Sync selected experiment with keyboard navigation + useEffect(() => { + if (isInLabTab && labTabIndex > 0 && labTabIndex <= experiments.length) { + setSelectedExperiment(experiments[labTabIndex - 1]) + setHasMouseInteracted(false) + } else { + setSelectedExperiment(null) + } + }, [labTabIndex, isInLabTab, experiments, setSelectedExperiment]) + + const handleExperimentClick = useCallback((data: any) => { + window.open( + `https://lab.basement.studio/experiments/${data.url}`, + "_blank" + ) + }, []) + + // Enter key handler + useKeyPress( + "Enter", + useCallback(() => { + if (isInLabTab && !isInGame) { + if (mouseHoveredExperiment) { + handleExperimentClick(mouseHoveredExperiment) + } else if (labTabIndex > 0 && labTabIndex <= experiments.length) { + handleExperimentClick(experiments[labTabIndex - 1]) + } + } + }, [ + isInLabTab, + labTabIndex, + experiments, + handleExperimentClick, + mouseHoveredExperiment, + isInGame + ]) + ) + + // Scroll calculation (visible items only) + const scrollStep = LIST_ITEM_HEIGHT + const totalItems = experiments.length + 1 // +1 for VIEW MORE + const visibleCount = Math.floor(height / scrollStep) + const maxScroll = Math.max(0, totalItems * scrollStep - height) + + const scrollOffset = useMemo(() => { + if (labTabIndex <= 6) return 0 + return Math.min((labTabIndex - 7) * scrollStep, maxScroll) + }, [labTabIndex, scrollStep, maxScroll]) + + const firstVisible = Math.floor(scrollOffset / scrollStep) + const lastVisible = Math.min( + totalItems - 1, + firstVisible + visibleCount + 1 + ) + + // Scrollbar + const scrollbarHeight = Math.max( + 20, + (height / (totalItems * scrollStep)) * height + ) + const scrollbarY = + maxScroll > 0 ? (scrollOffset / maxScroll) * (height - scrollbarHeight) : 0 + + return ( + + {/* List border container */} + + + {/* Scrollbar */} + {totalItems * scrollStep > height && ( + + )} + + {/* List items (only render visible) */} + + {experiments.map((data, idx) => { + if (idx < firstVisible || idx > lastVisible) return null + const itemY = idx * scrollStep - scrollOffset + return ( + { + setSourceHoverStates((prev) => { + const next = [...prev] + next[idx] = hover + return next + }) + }} + onExperimentClick={handleExperimentClick} + /> + ) + })} + + {/* VIEW MORE button */} + {experiments.length > 0 && ( + = firstVisible && + experiments.length <= lastVisible + } + /> + )} + + + ) +} + +// --- Experiment Item --- + +interface ExperimentItemProps { + data: any + idx: number + y: number + width: number + isInLabTab: boolean + labTabIndex: number + isSourceButtonSelected: boolean + hasMouseInteracted: boolean + mouseHoveredExperiment: any + selectedExperiment: any + sourceHoverState: boolean + setCursor: ReturnType + setSelectedExperiment: (exp: any) => void + setMouseHoveredExperiment: (exp: any) => void + setHasMouseInteracted: (val: boolean) => void + setSourceHoverState: (hover: boolean) => void + onExperimentClick: (data: any) => void +} + +const ExperimentItem = memo(function ExperimentItem({ + data, + idx, + y, + width, + isInLabTab, + labTabIndex, + isSourceButtonSelected, + hasMouseInteracted, + mouseHoveredExperiment, + selectedExperiment, + sourceHoverState, + setCursor, + setSelectedExperiment, + setMouseHoveredExperiment, + setHasMouseInteracted, + setSourceHoverState, + onExperimentClick +}: ExperimentItemProps) { + const isHovered = + (!hasMouseInteracted && + !mouseHoveredExperiment && + isInLabTab && + labTabIndex === idx + 1 && + !isSourceButtonSelected) || + (selectedExperiment?._title === data._title && + !isSourceButtonSelected && + labTabIndex === idx + 1) || + mouseHoveredExperiment?._title === data._title + + const isSourceHovered = + (!hasMouseInteracted && + !mouseHoveredExperiment && + isInLabTab && + labTabIndex === idx + 1 && + isSourceButtonSelected) || + sourceHoverState + + const highlighted = isHovered || isSourceHovered + const titleWidth = width * 0.85 + const sourceWidth = width * 0.15 + + return ( + + {/* Row background + border */} + { + e.stopPropagation() + onExperimentClick(data) + }} + onPointerOver={(e) => { + e.stopPropagation() + setCursor("alias") + setSelectedExperiment(data) + setMouseHoveredExperiment(data) + setHasMouseInteracted(true) + }} + onPointerOut={(e) => { + e.stopPropagation() + setCursor("default") + setMouseHoveredExperiment(null) + }} + /> + + {/* Bottom border line */} + + + {/* Title text */} + + + {/* SOURCE button */} + + { + e.stopPropagation() + window.open( + `https://github.com/basementstudio/basement-laboratory/tree/main/src/experiments/${data.url}`, + "_blank" + ) + }} + onPointerOver={(e) => { + e.stopPropagation() + setCursor("alias") + setSelectedExperiment(data) + setSourceHoverState(true) + }} + onPointerOut={(e) => { + e.stopPropagation() + setCursor("default") + setSourceHoverState(false) + if (!mouseHoveredExperiment) { + setSelectedExperiment(null) + } + }} + /> + + {/* Underline on source hover */} + {isSourceHovered && ( + + )} + + + ) +}) + +// --- View More Button --- + +const ViewMore = ({ + y, + width, + setSelectedExperiment, + visible +}: { + y: number + width: number + setSelectedExperiment: (experiment: any) => void + visible: boolean +}) => { + const [isHovered, setIsHovered] = useState(false) + const labTabIndex = useArcadeStore((state) => state.labTabIndex) + const isInLabTab = useArcadeStore((state) => state.isInLabTab) + const labTabs = useArcadeStore((state) => state.labTabs) + const setCursor = useCursor() + + const handleClick = useCallback(() => { + window.open("https://lab.basement.studio/", "_blank") + }, []) + + // VIEW MORE is at labTabs.length - 3 (after all experiments, before featured) + const isSelected = isInLabTab && labTabIndex === labTabs.length - 3 + const highlighted = isHovered || isSelected + + useKeyPress( + "Enter", + useCallback(() => { + if (isSelected) handleClick() + }, [isSelected, handleClick]) + ) + + if (!visible) return null + + return ( + + handleClick()} + onPointerOver={(e) => { + e.stopPropagation() + setCursor("alias") + setIsHovered(true) + setSelectedExperiment(null) + }} + onPointerOut={(e) => { + e.stopPropagation() + setCursor("default") + setIsHovered(false) + }} + /> + + + ) +} diff --git a/src/components/arcade-screen/arcade-ui/sections/preview.tsx b/src/components/arcade-screen/arcade-ui/sections/preview.tsx new file mode 100644 index 00000000..0c0e9d96 --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/sections/preview.tsx @@ -0,0 +1,70 @@ +import { Suspense } from "react" + +import { useAssets } from "@/components/assets-provider" + +import { COLORS, FONT_SIZE_SMALL } from "../constants" +import { UIImage } from "../ui-image" +import { UIPanel } from "../ui-panel" +import { UIText } from "../ui-text" + +interface PreviewProps { + selectedExperiment: any + width: number + height: number +} + +export const Preview = ({ + selectedExperiment, + width, + height +}: PreviewProps) => { + const { arcade } = useAssets() + + const imageWidth = width + const imageHeight = width * (9 / 16) // 16:9 aspect ratio + const descY = imageHeight + 10 + + const imageSrc = selectedExperiment?.cover?.url ?? arcade.placeholderLab + + const description = selectedExperiment?.description + ? selectedExperiment.description.toUpperCase().slice(0, 100) + + (selectedExperiment.description.length > 100 ? "..." : "") + : "" + + return ( + + {/* Image container with border */} + + + {/* Image */} + + + + + {/* Description text */} + {description && ( + + )} + + ) +} diff --git a/src/components/arcade-screen/arcade-ui/sections/title-header.tsx b/src/components/arcade-screen/arcade-ui/sections/title-header.tsx new file mode 100644 index 00000000..4d880cab --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/sections/title-header.tsx @@ -0,0 +1,70 @@ +import { COLORS, FONT_SIZE_HEADER } from "../constants" +import { UIPanel } from "../ui-panel" +import { UIText } from "../ui-text" + +interface TitleHeaderProps { + y: number + contentWidth: number + leftWidth: number + paddingX: number +} + +export const TitleHeader = ({ + y, + contentWidth, + leftWidth, + paddingX +}: TitleHeaderProps) => { + return ( + + {/* Horizontal separator line */} + + + {/* "EXPERIMENTS" label */} + + + + + + + {/* "PREVIEW" label — at 60% of content width */} + + + + + + + ) +} diff --git a/src/components/arcade-screen/arcade-ui/sections/wrapper-tags.tsx b/src/components/arcade-screen/arcade-ui/sections/wrapper-tags.tsx new file mode 100644 index 00000000..f29bc1a6 --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/sections/wrapper-tags.tsx @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useState } from "react" + +import { useNavigationStore } from "@/components/navigation-handler/navigation-store" +import { useHandleNavigation } from "@/hooks/use-handle-navigation" +import { useKeyPress } from "@/hooks/use-key-press" +import { useCursor } from "@/hooks/use-mouse" +import { useArcadeStore } from "@/store/arcade-store" + +import { COLORS, FONT_SIZE_LABEL, FONT_SIZE_SMALL } from "../constants" +import { UIPanel } from "../ui-panel" +import { UIText } from "../ui-text" + +interface WrapperTagsProps { + innerWidth: number + innerHeight: number +} + +export const WrapperTags = ({ innerWidth, innerHeight }: WrapperTagsProps) => { + const { handleNavigation } = useHandleNavigation() + const isInLabTab = useArcadeStore((state) => state.isInLabTab) + const labTabIndex = useArcadeStore((state) => state.labTabIndex) + const setCurrentTabIndex = useNavigationStore( + (state) => state.setCurrentTabIndex + ) + const setCursor = useCursor() + + const handleClose = useCallback(() => { + handleNavigation("/") + setCurrentTabIndex(-1) + }, [handleNavigation, setCurrentTabIndex]) + + const [hoverClose, setHoverClose] = useState(false) + + // Auto-highlight when navigated to via stick/keyboard + useEffect(() => { + if (isInLabTab && labTabIndex === 0) { + setHoverClose(true) + } else { + setHoverClose(false) + } + }, [isInLabTab, labTabIndex]) + + useKeyPress( + "Enter", + useCallback(() => { + if (isInLabTab && labTabIndex === 0) { + handleClose() + } + }, [isInLabTab, labTabIndex, handleClose]) + ) + + const isHighlighted = hoverClose || (isInLabTab && labTabIndex === 0) + + return ( + <> + {/* CLOSE [ESC] button — positioned above top edge */} + + handleClose()} + onPointerOver={(e) => { + e.stopPropagation() + setCursor("pointer") + setHoverClose(true) + }} + onPointerOut={(e) => { + e.stopPropagation() + setCursor("default") + if (!(isInLabTab && labTabIndex === 0)) { + setHoverClose(false) + } + }} + > + + + + + {/* LABS V1.0 label — positioned below bottom edge */} + + + + + + + ) +} diff --git a/src/components/arcade-screen/arcade-ui/ui-image.tsx b/src/components/arcade-screen/arcade-ui/ui-image.tsx new file mode 100644 index 00000000..218c40f7 --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/ui-image.tsx @@ -0,0 +1,61 @@ +import { useTexture } from "@react-three/drei" +import { memo, useEffect, useMemo, useRef } from "react" + +import { createImageMaterial } from "@/shaders/material-arcade-image" + +interface UIImageProps { + src: string + width: number + height: number + position?: [number, number, number] + opacity?: number + renderOrder?: number +} + +export const UIImage = memo(function UIImage({ + src, + width, + height, + position = [0, 0, 0], + opacity = 1, + renderOrder = 0 +}: UIImageProps) { + const tex = useTexture(src) + + const { material, uniforms } = useMemo( + () => + createImageMaterial({ + map: tex, + opacity, + imageAspect: tex.image ? tex.image.width / tex.image.height : 1, + containerAspect: width / height + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + // Track src changes + const prevSrc = useRef(src) + useEffect(() => { + if (prevSrc.current !== src) { + prevSrc.current = src + } + uniforms.uMap.value = tex + if (tex.image) { + uniforms.uImageAspect.value = tex.image.width / tex.image.height + } + uniforms.uContainerAspect.value = width / height + uniforms.uOpacity.value = opacity + }, [tex, width, height, opacity, uniforms, src]) + + return ( + + + + + ) +}) diff --git a/src/components/arcade-screen/arcade-ui/ui-panel.tsx b/src/components/arcade-screen/arcade-ui/ui-panel.tsx new file mode 100644 index 00000000..17756bf6 --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/ui-panel.tsx @@ -0,0 +1,99 @@ +import { memo, useMemo, useRef } from "react" +import { Color, Vector2 } from "three" + +import { createPanelMaterial } from "@/shaders/material-arcade-panel" + +// Three.js Color doesn't understand "transparent" — treat it as black +// (panels sit on a black background, so black = visually transparent) +const toColor = (c: string) => new Color(c === "transparent" ? "#000000" : c) + +interface UIPanelProps { + width: number + height: number + position?: [number, number, number] + bgColor?: string + borderColor?: string + borderWidth?: number + radius?: number + opacity?: number + renderOrder?: number + onClick?: (e: any) => void + onPointerOver?: (e: any) => void + onPointerOut?: (e: any) => void + children?: React.ReactNode +} + +export const UIPanel = memo(function UIPanel({ + width, + height, + position = [0, 0, 0], + bgColor = "#000000", + borderColor = "#FF4D00", + borderWidth = 0, + radius = 0, + opacity = 1, + renderOrder = 0, + onClick, + onPointerOver, + onPointerOut, + children +}: UIPanelProps) { + const { material, uniforms } = useMemo( + () => + createPanelMaterial({ + bgColor: toColor(bgColor), + borderColor: toColor(borderColor), + borderWidth, + radius, + size: new Vector2(width, height), + opacity + }), + // Only recreate material on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + // Update uniforms reactively without recreating material + const prevRef = useRef({ bgColor, borderColor, borderWidth, radius, opacity, width, height }) + if ( + prevRef.current.bgColor !== bgColor || + prevRef.current.borderColor !== borderColor || + prevRef.current.borderWidth !== borderWidth || + prevRef.current.radius !== radius || + prevRef.current.opacity !== opacity || + prevRef.current.width !== width || + prevRef.current.height !== height + ) { + prevRef.current = { bgColor, borderColor, borderWidth, radius, opacity, width, height } + uniforms.uBgColor.value.set( + ...toColor(bgColor).toArray() as [number, number, number] + ) + uniforms.uBorderColor.value.set( + ...toColor(borderColor).toArray() as [number, number, number] + ) + uniforms.uBorderWidth.value = borderWidth + uniforms.uRadius.value = radius + uniforms.uSize.value.set(width, height) + uniforms.uOpacity.value = opacity + } + + return ( + + + + + + {children && ( + + {children} + + )} + + ) +}) diff --git a/src/components/arcade-screen/arcade-ui/ui-text.tsx b/src/components/arcade-screen/arcade-ui/ui-text.tsx new file mode 100644 index 00000000..fe06a262 --- /dev/null +++ b/src/components/arcade-screen/arcade-ui/ui-text.tsx @@ -0,0 +1,71 @@ +import { memo, useMemo, useRef } from "react" +import { Color } from "three" + +import { createTextMaterial } from "@/shaders/material-arcade-text" + +import { buildTextGeometry, measureText, useMsdfFont } from "./msdf-font" + +interface UITextProps { + text: string + fontSize: number + color?: string + position?: [number, number, number] + anchorX?: "left" | "center" | "right" + opacity?: number + renderOrder?: number +} + +export const UIText = memo(function UIText({ + text, + fontSize, + color = "#FF4D00", + position = [0, 0, 0], + anchorX = "left", + opacity = 1, + renderOrder = 0 +}: UITextProps) { + const font = useMsdfFont() + + const { material, uniforms } = useMemo( + () => + createTextMaterial({ + atlas: font.atlas, + color: new Color(color), + opacity + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [font.atlas] + ) + + // Update color/opacity without recreating material + const prevRef = useRef({ color, opacity }) + if (prevRef.current.color !== color || prevRef.current.opacity !== opacity) { + prevRef.current = { color, opacity } + uniforms.uColor.value.set( + ...new Color(color).toArray() as [number, number, number] + ) + uniforms.uOpacity.value = opacity + } + + const geometry = useMemo( + () => buildTextGeometry(text, fontSize, font), + [text, fontSize, font] + ) + + // Compute anchor offset + const offsetX = useMemo(() => { + if (anchorX === "left") return 0 + const w = measureText(text, fontSize, font) + return anchorX === "center" ? -w / 2 : -w + }, [text, fontSize, font, anchorX]) + + return ( + + + + ) +}) diff --git a/src/components/arcade-screen/index.tsx b/src/components/arcade-screen/index.tsx index f5ae48c9..4f8520a9 100644 --- a/src/components/arcade-screen/index.tsx +++ b/src/components/arcade-screen/index.tsx @@ -32,10 +32,10 @@ const ArcadeGame = dynamic( } ) -const ScreenUI = dynamic( +const ArcadeUI = dynamic( () => - import("./screen-ui").then((mod) => ({ - default: mod.ScreenUI + import("./arcade-ui").then((mod) => ({ + default: mod.ArcadeUI })), { loading: () => null, @@ -86,6 +86,7 @@ export const ArcadeScreen = () => { if (!arcadeScreen) return videoTexture.flipY = false + videoTexture.colorSpace = "srgb" if (!hasVisitedArcade) { if (isLabRoute) { @@ -169,7 +170,7 @@ export const ArcadeScreen = () => { /> - + diff --git a/src/components/arcade-screen/screen-ui.tsx b/src/components/arcade-screen/screen-ui.tsx deleted file mode 100644 index aafc2757..00000000 --- a/src/components/arcade-screen/screen-ui.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { Container } from "@react-three/uikit" -import { useEffect, useMemo, useRef, useState } from "react" - -import { fetchLaboratory } from "@/actions/laboratory-fetch" -import { useArcadeStore } from "@/store/arcade-store" - -import { ArcadeFeatured } from "./arcade-ui-components/arcade-featured" -import { ArcadeLabsList } from "./arcade-ui-components/arcade-labs-list" -import { ArcadePreview } from "./arcade-ui-components/arcade-preview" -import { ArcadeTitleTagsHeader } from "./arcade-ui-components/arcade-title-tags-header" -import { ArcadeWrapperTags } from "./arcade-ui-components/arcade-wrapper-tags" - -interface ScreenUIProps { - onLoad?: () => void - visible: boolean -} - -export const COLORS_THEME = { - primary: "#FF4D00", - black: "#000" -} - -export interface LabTab { - id: string - type: "button" | "experiment" | "featured" - title: string - url?: string - isClickable: boolean -} - -export const createLabTabs = (experiments: any[]): LabTab[] => { - const tabs: LabTab[] = [ - // Close button - { - id: "close", - type: "button", - title: "CLOSE [ESC]", - isClickable: true - }, - - // Experiments - ...experiments.map((exp) => ({ - id: `experiment-${exp._title}`, - type: "experiment" as const, - title: exp._title.toUpperCase(), - url: `https://lab.basement.studio/experiments/${exp.url}`, - isClickable: true - })), - - // View More button - { - id: "view-more", - type: "button", - title: "VIEW MORE", - url: "https://lab.basement.studio/", - isClickable: true - }, - - // Chronicles - { - id: "chronicles", - type: "featured", - title: "CHRONICLES", - url: "https://chronicles.basement.studio", - isClickable: true - }, - - // Looper - { - id: "looper", - type: "featured", - title: "LOOPER (COMING SOON)", - isClickable: false - } - ] - - return tabs -} - -export const ScreenUI = ({ onLoad, visible }: ScreenUIProps) => { - const onLoadRef = useRef(onLoad) - onLoadRef.current = onLoad - - const [experiments, setExperiments] = useState([]) - const [selectedExperiment, setSelectedExperiment] = useState(null) - - // Font URL for react-three/uikit - const fontFamilies = useMemo( - () => ({ - ffflauta: { - normal: "/fonts/ffflauta.json" - } - }), - [] - ) - - useEffect(() => { - if (visible) { - fetchLaboratory().then((data) => { - const experiments = data.projectList.items.map((item: any) => ({ - _title: item._title, - url: item.url, - cover: item.cover, - description: item.description as string | null - })) - setExperiments(experiments) - - const labTabs = createLabTabs(experiments) - useArcadeStore.getState().setLabTabs(labTabs) - - onLoadRef.current?.() - }) - } - }, [visible]) - - return ( - - - - - - - - - - - - - - ) -} diff --git a/src/components/doom-js/crt-mesh.tsx b/src/components/doom-js/crt-mesh.tsx index 9cfb1a44..63c6f10d 100644 --- a/src/components/doom-js/crt-mesh.tsx +++ b/src/components/doom-js/crt-mesh.tsx @@ -19,12 +19,12 @@ import { pow, mix, step, - timerLocal + time } from "three/tsl" const createCRTMaterial = () => { const uTexture = tslTexture(new Texture()) - const uTime = timerLocal() + const uTime = time const uCurvature = uniform(0.3) const uScanlineIntensity = uniform(0.75) const uScanlineCount = uniform(200) diff --git a/src/components/lamp/index.tsx b/src/components/lamp/index.tsx index 244f604a..25d7dbc0 100644 --- a/src/components/lamp/index.tsx +++ b/src/components/lamp/index.tsx @@ -114,6 +114,7 @@ export const Lamp = memo(function LampInner() { lightmap.minFilter = THREE.NearestFilter lightmap.magFilter = THREE.NearestFilter lightmap.colorSpace = THREE.NoColorSpace + lightmap.needsUpdate = true }, [lightmap]) useEffect(() => { @@ -230,8 +231,16 @@ export const Lamp = memo(function LampInner() { }, [shouldToggle]) useEffect(() => { - // @ts-ignore - if (lamp) lamp.material.uniforms.opacity.value = light ? 0 : 1 + if (lamp) { + // Ensure the lamp glow mesh uses additive blending so it brightens the scene + // instead of rendering as a solid opaque disc + const mat = lamp.material as THREE.Material + mat.blending = THREE.AdditiveBlending + mat.transparent = true + mat.depthWrite = false + // @ts-ignore + mat.uniforms.opacity.value = light ? 0 : 1 + } if (lampHandle) { // @ts-ignore lampHandle.current.material.uniforms.baseColor.value = light @@ -345,8 +354,6 @@ export const Lamp = memo(function LampInner() { /> - {lamp && } - {lamp && } diff --git a/src/components/loading/loading-scene/index.tsx b/src/components/loading/loading-scene/index.tsx index b143e7d0..4744a2ca 100644 --- a/src/components/loading/loading-scene/index.tsx +++ b/src/components/loading/loading-scene/index.tsx @@ -102,7 +102,7 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { const solidMaterial = solid.material as ShaderMaterial const flowDoubleFbo = useMemo(() => { - const fbo = doubleFbo(512, 512, { + const fbo = doubleFbo(256, 256, { magFilter: NearestFilter, minFilter: NearestFilter, type: HalfFloatType @@ -167,8 +167,7 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { renderCount.current++ const time = elapsedTime - // Update uTime (previously a separate useFrame) - ;(solid.material as any).uniforms.uTime.value = elapsedTime + // uTime uses TSL `time` singleton — auto-updates per render, no CPU pumping needed // Screen reveal fade (previously a separate useFrame) const appLoaded = useLoadingWorkerStore.getState().isAppLoaded @@ -278,6 +277,9 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { solidMaterial.uniforms.uScreenSize.value.set(size.width, size.height) } + // Skip flow simulation during screen reveal — flow fades out anyway + if (uScreenReveal.current > 0) return + gl.setRenderTarget(null) vRefsFloor.smoothPointer.lerp(pointer, Math.min(delta * 10, 1)) diff --git a/src/components/map/bakes.tsx b/src/components/map/bakes.tsx index f770d8ea..bbc7bcb5 100644 --- a/src/components/map/bakes.tsx +++ b/src/components/map/bakes.tsx @@ -105,6 +105,7 @@ const useBakes = (): Record => { map.minFilter = NearestFilter map.magFilter = NearestFilter map.colorSpace = NoColorSpace + map.needsUpdate = true for (const meshName of meshNames) { if (!maps[meshName]) { @@ -121,6 +122,7 @@ const useBakes = (): Record => { map.minFilter = NearestFilter map.magFilter = NearestFilter map.colorSpace = NoColorSpace + map.needsUpdate = true for (const meshName of meshNames) { if (!maps[meshName]) { @@ -136,6 +138,7 @@ const useBakes = (): Record => { map.minFilter = NearestFilter map.magFilter = NearestFilter map.colorSpace = NoColorSpace + map.needsUpdate = true if (!maps[matcaps[index].mesh]) { maps[matcaps[index].mesh] = {} } @@ -151,6 +154,7 @@ const useBakes = (): Record => { map.generateMipmaps = false map.minFilter = NearestFilter map.magFilter = NearestFilter + map.needsUpdate = true const meshName = glassReflexes[index].mesh if (!maps[meshName]) { @@ -184,24 +188,14 @@ const Bakes = () => { const scene = useThree((state) => state.scene) - const setMainAppRunning = useAppLoadingStore( - (state) => state.setMainAppRunning - ) - const setCanRunMainApp = useAppLoadingStore((state) => state.setCanRunMainApp) useEffect(() => { setCanRunMainApp(true) - const timeout = setTimeout(() => { - setMainAppRunning(true) - }, 10) - const timeout2 = setTimeout(() => (cctvConfig.shouldBakeCCTV = true), 10) - - return () => { - clearTimeout(timeout) - clearTimeout(timeout2) - } - }, [setMainAppRunning, setCanRunMainApp]) + // Reveal is now triggered by renderer.tsx after compileAsync completes + const timeout = setTimeout(() => (cctvConfig.shouldBakeCCTV = true), 10) + return () => clearTimeout(timeout) + }, [setCanRunMainApp]) useEffect(() => { const addMaps = ({ mesh, maps }: { mesh: Mesh; maps: Bake }) => { diff --git a/src/components/postprocessing/renderer.tsx b/src/components/postprocessing/renderer.tsx index 48fae103..6e6385ca 100644 --- a/src/components/postprocessing/renderer.tsx +++ b/src/components/postprocessing/renderer.tsx @@ -63,7 +63,7 @@ function RendererInner({ sceneChildren }: RendererProps) { return rt }, []) - const { canRunMainApp } = useAppLoadingStore() + const canRunMainApp = useAppLoadingStore((s) => s.canRunMainApp) const mainScene = useMemo(() => new Scene(), []) const postProcessingScene = useMemo(() => new Scene(), []) @@ -71,6 +71,8 @@ function RendererInner({ sceneChildren }: RendererProps) { const mainCamera = useNavigationStore((state) => state.mainCamera) const lastSize = useRef({ w: 0, h: 0 }) + const compiledRef = useRef(false) + const compilingRef = useRef(false) useFrameCallback(({ gl, size }) => { // Resize render target when viewport changes (avoids useThree re-renders) @@ -83,6 +85,40 @@ function RendererInner({ sceneChildren }: RendererProps) { if (!mainCamera || !postProcessingCameraRef.current || !canRunMainApp) return + // Pre-compile all WebGPU pipelines before first render to avoid jank + if (!compiledRef.current) { + if (!compilingRef.current) { + compilingRef.current = true + + // Suppress known-harmless "attribute not found" warnings from Three.js + // during pipeline compilation (Line2, Points geometries lack standard attrs) + const origWarn = console.warn + console.warn = (...args: any[]) => { + const msg = typeof args[0] === "string" ? args[0] : "" + if ( + msg.includes("not found on geometry") || + msg.includes("Video textures must use") + ) + return + origWarn.apply(console, args) + } + + Promise.all([ + (gl as any).compileAsync(mainScene, mainCamera), + (gl as any).compileAsync( + postProcessingScene, + postProcessingCameraRef.current + ) + ]).then(() => { + console.warn = origWarn + compiledRef.current = true + // Signal loading scene to start reveal after pipelines are ready + useAppLoadingStore.getState().setMainAppRunning(true) + }) + } + return + } + // main render gl.outputColorSpace = LinearSRGBColorSpace gl.toneMapping = NoToneMapping diff --git a/src/components/scene/index.tsx b/src/components/scene/index.tsx index 89d6a167..6a1dcdf1 100644 --- a/src/components/scene/index.tsx +++ b/src/components/scene/index.tsx @@ -158,11 +158,20 @@ export const Scene = () => { } }} gl={async (defaultProps) => { + // Enable HDR extended tone mapping on the canvas + const srgbConfig = (THREE.ColorManagement as any).spaces[ + THREE.SRGBColorSpace + ] + if (srgbConfig?.outputColorSpaceConfig) { + srgbConfig.outputColorSpaceConfig.toneMappingMode = "extended" + } + const renderer = new WebGPURenderer({ canvas: defaultProps.canvas as HTMLCanvasElement, antialias: false, - alpha: false - }) + alpha: false, + outputType: THREE.HalfFloatType + } as ConstructorParameters[0]) await renderer.init() renderer.outputColorSpace = THREE.SRGBColorSpace renderer.toneMapping = THREE.NoToneMapping diff --git a/src/hooks/use-preload-assets.ts b/src/hooks/use-preload-assets.ts index b46773c4..30e4b205 100644 --- a/src/hooks/use-preload-assets.ts +++ b/src/hooks/use-preload-assets.ts @@ -82,14 +82,14 @@ const assetUrlCache = new Map< const getAssetFormat = ( url: string -): { as: PreloadOptions["as"]; type: PreloadOptions["type"] } => { +): { as: PreloadOptions["as"]; type: PreloadOptions["type"] } | null => { // Return from cache if already computed if (assetUrlCache.has(url)) { return assetUrlCache.get(url)! } const extension = url.split(".").pop()?.toLowerCase() || "" - let result: { as: PreloadOptions["as"]; type: PreloadOptions["type"] } + let result: { as: PreloadOptions["as"]; type: PreloadOptions["type"] } | null switch (extension) { case "png": @@ -116,21 +116,14 @@ const getAssetFormat = ( case "mp4": result = { as: "video", type: "video/mp4" } break - case "exr": - result = { as: "fetch", type: "image/x-exr" } - break - case "glb": - result = { as: "fetch", type: "model/gltf-binary" } - break - case "gltf": - result = { as: "fetch", type: "model/gltf+json" } - break default: - result = { as: "fetch", type: undefined } + // Skip preloading for formats without browser-supported `as` values + // (e.g. .exr, .glb, .gltf) — browsers warn on `as="fetch"` preload links + result = null } // Cache the result - assetUrlCache.set(url, result) + if (result) assetUrlCache.set(url, result) return result } @@ -197,9 +190,10 @@ const preloadAllAssets = (obj: any) => { // Preload all URLs urls.forEach((url) => { - const { as, type } = getAssetFormat(url) + const format = getAssetFormat(url) + if (!format) return // Add crossorigin attribute to match credentials mode of the requests - preload(url, { as, type, crossOrigin: "anonymous" }) + preload(url, { as: format.as, type: format.type, crossOrigin: "anonymous" }) }) } diff --git a/src/hooks/use-video-resume.ts b/src/hooks/use-video-resume.ts index 34616488..26eabfb8 100644 --- a/src/hooks/use-video-resume.ts +++ b/src/hooks/use-video-resume.ts @@ -68,6 +68,7 @@ export const createVideoTextureWithResume = (url: string) => { }) const texture = new THREE.VideoTexture(videoElement) + texture.colorSpace = THREE.SRGBColorSpace texture.userData = { ...texture.userData, diff --git a/src/shaders/material-arcade-image/index.ts b/src/shaders/material-arcade-image/index.ts new file mode 100644 index 00000000..0e0476f3 --- /dev/null +++ b/src/shaders/material-arcade-image/index.ts @@ -0,0 +1,49 @@ +import { Texture } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec2, + vec4, + uniform, + uv, + texture, + select +} from "three/tsl" + +export const createImageMaterial = ({ + map = new Texture(), + opacity = 1, + imageAspect = 1, + containerAspect = 1 +} = {}) => { + const uMap = texture(map) + const uOpacity = uniform(opacity) + const uImageAspect = uniform(imageAspect) + const uContainerAspect = uniform(containerAspect) + + const material = new NodeMaterial() + material.transparent = true + material.depthWrite = false + + material.colorNode = Fn(() => { + const vUv = uv() + + // Object-fit: cover — scale UV to crop and fill + const scale = select( + uImageAspect.greaterThan(uContainerAspect), + vec2(uContainerAspect.div(uImageAspect), 1.0), + vec2(1.0, uImageAspect.div(uContainerAspect)) + ) + const offset = vec2(0.5, 0.5).sub(scale.mul(0.5)) + const adjustedUv = vUv.mul(scale).add(offset) + + const color = uMap.sample(adjustedUv) + return vec4(color.rgb, color.a.mul(uOpacity)) + })() + + return { + material, + uniforms: { uMap, uOpacity, uImageAspect, uContainerAspect } + } +} diff --git a/src/shaders/material-arcade-panel/index.ts b/src/shaders/material-arcade-panel/index.ts new file mode 100644 index 00000000..2b111ff1 --- /dev/null +++ b/src/shaders/material-arcade-panel/index.ts @@ -0,0 +1,103 @@ +import { Color, Vector2 } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec2, + vec4, + uniform, + uv, + abs, + max, + min, + mix, + smoothstep +} from "three/tsl" + +export const createPanelMaterial = ({ + bgColor = new Color(0, 0, 0), + borderColor = new Color(1, 0.3, 0), + borderWidth = 0, + radius = 0, + size = new Vector2(1, 1), + opacity = 1 +} = {}) => { + const uBgColor = uniform(bgColor) + const uBorderColor = uniform(borderColor) + const uBorderWidth = uniform(borderWidth) + const uRadius = uniform(radius) + const uSize = uniform(size) + const uOpacity = uniform(opacity) + + const material = new NodeMaterial() + material.transparent = true + material.depthWrite = false + + // Rounded rectangle SDF in UV space + const roundedRectSDF = /* @__PURE__ */ Fn( + ([p, halfSize, r]: [any, any, any]) => { + const rVec = vec2(r, r) + const q = abs(p).sub(halfSize).add(rVec) + const outerDist = max(q, vec2(0.0, 0.0)) + const outerLen = outerDist.length() + const innerDist = min(max(q.x, q.y), float(0.0)) + return outerLen.add(innerDist).sub(r) + } + ) + + material.colorNode = Fn(() => { + const vUv = uv() + + // Center UV to [-0.5, 0.5] + const p = vUv.sub(0.5) + + // Convert radius and borderWidth from virtual units to UV space + const radiusUv = vec2(uRadius.div(uSize.x), uRadius.div(uSize.y)) + const r = min(radiusUv.x, radiusUv.y) + + const borderUv = vec2(uBorderWidth.div(uSize.x), uBorderWidth.div(uSize.y)) + + const halfSize = vec2(0.5, 0.5) + + // SDF distance (negative inside, positive outside) + const d = roundedRectSDF(p, halfSize, r) + + // Anti-aliased edge: ~1 pixel smooth width in UV space (adaptive to panel size) + const edgeSmooth = float(1.0).div(max(uSize.x, uSize.y)) + const outerAlpha = float(1.0).sub(smoothstep(float(0.0), edgeSmooth, d)) + + // Discard fully outside + outerAlpha.lessThan(0.001).discard() + + // Border region: inside the shape but within borderWidth of edge + const borderSdf = roundedRectSDF( + p, + halfSize.sub(borderUv), + max(r.sub(min(borderUv.x, borderUv.y)), float(0.0)) + ) + // borderMask: 1 deep inside (bg), 0 at border edge + const borderMask = float(1.0).sub( + smoothstep(edgeSmooth.negate(), edgeSmooth, borderSdf) + ) + + // Mix background and border colors + // Use standalone mix() — the .mix() method on nodes swaps arguments + const hasBorder = smoothstep(float(0.0), float(0.001), uBorderWidth) + const t = hasBorder.mul(float(1.0).sub(borderMask)) + const finalColor = mix(uBgColor, uBorderColor, t) + + return vec4(finalColor, outerAlpha.mul(uOpacity)) + })() + + return { + material, + uniforms: { + uBgColor, + uBorderColor, + uBorderWidth, + uRadius, + uSize, + uOpacity + } + } +} diff --git a/src/shaders/material-arcade-text/index.ts b/src/shaders/material-arcade-text/index.ts new file mode 100644 index 00000000..6cfb1915 --- /dev/null +++ b/src/shaders/material-arcade-text/index.ts @@ -0,0 +1,57 @@ +import { Color, Texture } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec4, + uniform, + uv, + texture, + max, + min, + smoothstep +} from "three/tsl" + +export const createTextMaterial = ({ + atlas = new Texture(), + color = new Color(1, 0.3, 0), + opacity = 1 +} = {}) => { + const uAtlas = texture(atlas) + const uColor = uniform(color) + const uOpacity = uniform(opacity) + + const material = new NodeMaterial() + material.transparent = true + material.depthWrite = false + + material.colorNode = Fn(() => { + const vUv = uv() + + // Sample the 3-channel MSDF atlas + const msdf = uAtlas.sample(vUv) + const r = msdf.r + const g = msdf.g + const b = msdf.b + + // Standard MSDF median + const median = max(min(r, g), min(max(r, g), b)) + + // Signed distance: 0.5 is the exact edge + const sd = median.sub(0.5) + + // Anti-aliased alpha via smoothstep + // Tighter range = crisper text (good for small render target sizes) + const alpha = smoothstep(float(-0.05), float(0.05), sd) + + // Discard fully transparent fragments + alpha.lessThan(0.01).discard() + + return vec4(uColor, alpha.mul(uOpacity)) + })() + + return { + material, + uniforms: { uAtlas, uColor, uOpacity } + } +} diff --git a/src/shaders/material-characters/index.ts b/src/shaders/material-characters/index.ts index 8655a270..c4527d5f 100644 --- a/src/shaders/material-characters/index.ts +++ b/src/shaders/material-characters/index.ts @@ -51,8 +51,10 @@ export const createCharacterMaterial = (mapConfigs?: MapConfig[]) => { const uFadeFactor = uniform(0) // Map texture uniforms (set at creation time) + // Pass uv() as uvNode so the TextureNode constructor sets updateMatrix=false, + // preventing the automatic matrix transform (we apply it manually in the shader) const mapCount = mapConfigs?.length ?? 0 - const uMapTextures = mapConfigs?.map((c) => tslTexture(c.map)) ?? [] + const uMapTextures = mapConfigs?.map((c) => tslTexture(c.map, uv())) ?? [] const uMapTransforms = mapConfigs?.map((c) => uniform(c.mapTransform)) ?? [] diff --git a/src/shaders/material-flow/index.ts b/src/shaders/material-flow/index.ts index 06250def..20871630 100644 --- a/src/shaders/material-flow/index.ts +++ b/src/shaders/material-flow/index.ts @@ -12,10 +12,11 @@ import { step, mix, length, - mx_noise_float + fract, + dot } from "three/tsl" -const FLOW_RESOLUTION = 512 +const FLOW_RESOLUTION = 256 export const createFlowMaterial = () => { const uFrame = uniform(0) @@ -26,6 +27,13 @@ export const createFlowMaterial = () => { const material = new NodeMaterial() + // Fast hash-based 3D noise (replaces expensive mx_noise_float) + const hashNoise3d = /* @__PURE__ */ Fn(([p]: [any]) => { + const p3 = fract(p.mul(vec3(0.1031, 0.1030, 0.0973))).toVar() + p3.addAssign(dot(p3, vec3(p3.y, p3.z, p3.x).add(33.33))) + return fract(p3.x.add(p3.y).mul(p3.z)).mul(2.0).sub(1.0) + }) + const invRes = vec2(1.0 / FLOW_RESOLUTION, 1.0 / FLOW_RESOLUTION) // samplePrev: sample center + 4 neighbors, find smallest growth @@ -74,8 +82,8 @@ export const createFlowMaterial = () => { // Increment growth finalSample.g.addAssign(0.02) - // 2D noise (use 3D noise with z=0 as cnoise2 approximation) - const noise = mx_noise_float(vec3(pixel.x, pixel.y, float(0.0))) + // 2D hash noise (cheap replacement for mx_noise_float) + const noise = hashNoise3d(vec3(pixel.x, pixel.y, float(0.0))) // Kill wave if growth exceeds threshold: g > 2.0 + noise → set g = 1000 const killThreshold = float(2.0).add(noise) diff --git a/src/shaders/material-global-shader/index.tsx b/src/shaders/material-global-shader/index.tsx index 39e0624c..d60e7cfc 100644 --- a/src/shaders/material-global-shader/index.tsx +++ b/src/shaders/material-global-shader/index.tsx @@ -1,8 +1,10 @@ import { Color, + DataTexture, Matrix3, MeshStandardMaterial, - Texture, + RGBAFormat, + UnsignedByteType, Vector2, Vector3 } from "three" @@ -32,7 +34,7 @@ import { floor, mod, select, - timerGlobal, + time, smoothstep, sin, abs @@ -41,8 +43,13 @@ import { basicLight } from "@/shaders/utils/basic-light" export const GLOBAL_SHADER_MATERIAL_NAME = "global-shader-material" -// --- Shared blank texture (reused as placeholder for all texture slots) --- -const BLANK_TEXTURE = new Texture() +// --- Shared blank texture (1×1 white pixel — valid GPU format for WebGPU pipeline compilation) --- +const BLANK_TEXTURE = (() => { + const data = new Uint8Array([255, 255, 255, 255]) + const tex = new DataTexture(data, 1, 1, RGBAFormat, UnsignedByteType) + tex.needsUpdate = true + return tex +})() // --- Shared global uniforms (same value across all materials) --- const sharedUTime = uniform(0) @@ -191,11 +198,18 @@ export const createGlobalShaderMaterial = ( material.transparent = baseOpacity < 1 || isTransparent || isDaylight material.side = baseMaterial.side + material.depthWrite = baseMaterial.depthWrite + material.blending = baseMaterial.blending // --- Shared vertex/fragment nodes --- const vUv1 = uv() - const aUv1 = attribute("uv1", "vec2") + const aUv1 = (Fn as any)((builder: any) => { + if (builder.geometry.hasAttribute("uv1")) { + return attribute("uv1", "vec2") + } + return vec2(0.0) + }, "vec2").once()() const vUv2 = select(aUv1.x.greaterThan(0.0), aUv1, vUv1) // --- Main shader computation --- @@ -247,7 +261,7 @@ export const createGlobalShaderMaterial = ( // Ornament cycling: 4-phase sine transition computed on GPU if (isOrnament) { - const t = timerGlobal() + const t = time const cyclePhase = t.mod(1.5).div(0.375) // [0, 4) const pd = cyclePhase.sub(uOrnamentPhase!).add(2.0).mod(4.0).sub(2.0) const transUp = smoothstep(float(-0.75), float(0.0), pd) @@ -257,7 +271,7 @@ export const createGlobalShaderMaterial = ( // Star ornament: intensity oscillation computed on GPU if (isOrnamentStar) { - const t = timerGlobal() + const t = time const starOsc = abs(sin(t.mul(2.5))) ei.assign(uOrnamentBaseIntensity!.mul(float(1.0).add(starOsc))) } diff --git a/src/shaders/material-net/index.ts b/src/shaders/material-net/index.ts index dea53151..9bad31bf 100644 --- a/src/shaders/material-net/index.ts +++ b/src/shaders/material-net/index.ts @@ -24,7 +24,12 @@ export const createNetMaterial = () => { // Vertex: displace position using DataTexture lookup via uv1 attribute material.positionNode = Fn(() => { - const aUv1 = attribute("uv1", "vec2") + const aUv1 = (Fn as any)((builder: any) => { + if (builder.geometry.hasAttribute("uv1")) { + return attribute("uv1", "vec2") + } + return vec2(0.0) + }, "vec2").once()() const dispUv = vec2( aUv1.x, float(1.0).sub(uCurrentFrame.div(uTotalFrames)) diff --git a/src/shaders/material-postprocessing/index.ts b/src/shaders/material-postprocessing/index.ts index 051028b9..1341081d 100644 --- a/src/shaders/material-postprocessing/index.ts +++ b/src/shaders/material-postprocessing/index.ts @@ -200,7 +200,7 @@ export const createPostProcessingMaterial = () => { const safeWeight = max(totalWeight, float(0.0001)) color.addAssign(bloom.div(safeWeight).mul(uBloomStrength).mul(bloomActive)) - color.assign(clamp(color, 0.0, 1.0)) + color.assign(max(color, vec3(0.0, 0.0, 0.0))) // Vignette const vignetteFactor = getVignetteFactorFn(vUv) diff --git a/src/shaders/material-screen/index.ts b/src/shaders/material-screen/index.ts index c36b3863..31ce4c6f 100644 --- a/src/shaders/material-screen/index.ts +++ b/src/shaders/material-screen/index.ts @@ -109,9 +109,9 @@ export const createScreenMaterial = () => { // CRT curve remap const remappedUv = curveRemapUV(interferenceUv).toVar() - // Flip y when uFlip is 1 + // Flip y: default flips for WebGPU convention, uFlip=1 un-flips for game mode remappedUv.y.assign( - mix(remappedUv.y, float(1.0).sub(remappedUv.y), uFlip) + mix(float(1.0).sub(remappedUv.y), remappedUv.y, uFlip) ) // Pixelation with exclusion zones (only when flipped) diff --git a/src/shaders/material-solid-reveal/index.ts b/src/shaders/material-solid-reveal/index.ts index 38059df6..68319b25 100644 --- a/src/shaders/material-solid-reveal/index.ts +++ b/src/shaders/material-solid-reveal/index.ts @@ -18,11 +18,13 @@ import { fract, pow, step, - distance + distance, + dot, + time } from "three/tsl" export const createSolidRevealMaterial = () => { - const uTime = uniform(0) + const uTime = time const uReveal = uniform(0) const uScreenReveal = uniform(0) const uFlowTexture = texture(new Texture()) @@ -32,6 +34,14 @@ export const createSolidRevealMaterial = () => { const material = new NodeMaterial() + // Fast hash noise for voxel-snapped positions (replaces one mx_noise_float) + // Perlin smoothness is wasted on quantized grid positions — hash is equivalent + const hashNoise3d = /* @__PURE__ */ Fn(([p]: [any]) => { + const p3 = fract(p.mul(vec3(0.1031, 0.1030, 0.0973))).toVar() + p3.addAssign(dot(p3, vec3(p3.y, p3.z, p3.x).add(33.33))) + return fract(p3.x.add(p3.y).mul(p3.z)).mul(2.0).sub(1.0) + }) + // worldToUv: project world position to screen UV via camera matrices const worldToUvFn = /* @__PURE__ */ Fn(([p]: [any]) => { const clipPos = cameraProjectionMatrix.mul(cameraViewMatrix).mul( @@ -61,10 +71,10 @@ export const createSolidRevealMaterial = () => { const voxelSize = float(15.0) const voxelCenter = round(p.mul(voxelSize)).div(voxelSize) - // 3D noise (equivalent to cnoise3) - const noiseSmall = mx_noise_float(voxelCenter.mul(20.0)) + // High-freq hash noise for voxel grid (reveal threshold, flow SDF modulation) + const noiseSmall = hashNoise3d(voxelCenter.mul(20.0)) - // Approximate 4D noise via time-shifted 3D noise (equivalent to cnoise4) + // Low-freq animated Perlin noise (creates visible sweeping wave pattern) const noiseBig = mx_noise_float( voxelCenter.mul(0.2).add(vec3(0, 0, uTime.mul(0.05))) ) @@ -110,8 +120,8 @@ export const createSolidRevealMaterial = () => { material.opacityNode = float(1.0) // Compatibility layer: consumer accesses material.uniforms.X.value + // uTime uses TSL `time` singleton — auto-updates per render, no CPU pumping needed ;(material as any).uniforms = { - uTime, uReveal, uScreenReveal, uFlowTexture, diff --git a/src/store/arcade-store.ts b/src/store/arcade-store.ts index e5677785..06181970 100644 --- a/src/store/arcade-store.ts +++ b/src/store/arcade-store.ts @@ -1,6 +1,6 @@ import { create } from "zustand" -import { LabTab } from "@/components/arcade-screen/screen-ui" +import { LabTab } from "@/components/arcade-screen/arcade-ui" interface ArcadeStore { isInGame: boolean From 6013c8140e16df59f3de5c2f1aeeba2fba184e2f Mon Sep 17 00:00:00 2001 From: ragojose Date: Fri, 6 Feb 2026 13:00:49 -0300 Subject: [PATCH 21/21] fix: correct upside-down 404 screen and arcade boot image for WebGPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Not-found material: change 180° rotation to X-mirror only, since WebGPU render targets have Y=0 at top (no Y negation needed unlike WebGL) - Screen material: add separate uFlipY uniform for render target Y-flip, restoring uFlip to game-mode-only behavior. Two sequential flips cancel when both active (DOOM game + RT inversion) - Arcade screen component: set uFlipY=1 for render targets, 0 for images/video Co-Authored-By: Claude Opus 4.6 --- src/components/arcade-screen/index.tsx | 4 ++++ src/shaders/material-not-found/index.ts | 4 ++-- src/shaders/material-screen/index.ts | 11 ++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/arcade-screen/index.tsx b/src/components/arcade-screen/index.tsx index 4f8520a9..6b97653c 100644 --- a/src/components/arcade-screen/index.tsx +++ b/src/components/arcade-screen/index.tsx @@ -91,6 +91,7 @@ export const ArcadeScreen = () => { if (!hasVisitedArcade) { if (isLabRoute) { screenUniforms.map.value = bootTexture + screenUniforms.uFlipY.value = 0 screenUniforms.uRevealProgress.value = 0.0 animate(0, 1, { @@ -102,6 +103,7 @@ export const ArcadeScreen = () => { onComplete: () => { if (screenUniforms.uRevealProgress.value >= 0.99) { screenUniforms.map.value = renderTarget.texture + screenUniforms.uFlipY.value = 1 setHasVisitedArcade(true) screenUniforms.uFlip.value = isInGame ? 1 : 0 } @@ -109,11 +111,13 @@ export const ArcadeScreen = () => { }) } else { screenUniforms.map.value = videoTexture + screenUniforms.uFlipY.value = 0 screenUniforms.uRevealProgress.value = 1.0 screenUniforms.uFlip.value = 0 } } else { screenUniforms.map.value = renderTarget.texture + screenUniforms.uFlipY.value = 1 screenUniforms.uFlip.value = isInGame ? 1 : 0 } diff --git a/src/shaders/material-not-found/index.ts b/src/shaders/material-not-found/index.ts index 5d11b4ab..6ee68010 100644 --- a/src/shaders/material-not-found/index.ts +++ b/src/shaders/material-not-found/index.ts @@ -52,8 +52,8 @@ export const createNotFoundMaterial = () => { uv2.x.assign(float(1.0).sub(uv2.x)) uv2.subAssign(0.5) - // 180° rotation (cosR=-1, sinR=0) + re-center - const shiftedUv = uv2.mul(-1.0).add(0.5) + // Mirror X only (WebGPU render targets have Y=0 at top, so no Y negate needed) + const shiftedUv = vec2(uv2.x.negate(), uv2.y).add(0.5) // Texture sampling with color bleeding const baseColor = tDiffuse.sample(shiftedUv) diff --git a/src/shaders/material-screen/index.ts b/src/shaders/material-screen/index.ts index 31ce4c6f..c6ebbbd4 100644 --- a/src/shaders/material-screen/index.ts +++ b/src/shaders/material-screen/index.ts @@ -74,6 +74,7 @@ export const createScreenMaterial = () => { const uTime = uniform(0) const uRevealProgress = uniform(1.0) const uFlip = uniform(0) + const uFlipY = uniform(0.0) const uIsGameRunning = uniform(0.0) const material = new NodeMaterial() @@ -109,9 +110,13 @@ export const createScreenMaterial = () => { // CRT curve remap const remappedUv = curveRemapUV(interferenceUv).toVar() - // Flip y: default flips for WebGPU convention, uFlip=1 un-flips for game mode + // Flip Y for render target textures (WebGPU RT has Y=0 at top vs WebGL Y=0 at bottom) remappedUv.y.assign( - mix(float(1.0).sub(remappedUv.y), remappedUv.y, uFlip) + mix(remappedUv.y, float(1.0).sub(remappedUv.y), uFlipY) + ) + // Game mode flip (DOOM orientation correction — cancels RT flip when both active) + remappedUv.y.assign( + mix(remappedUv.y, float(1.0).sub(remappedUv.y), uFlip) ) // Pixelation with exclusion zones (only when flipped) @@ -211,6 +216,6 @@ export const createScreenMaterial = () => { return { material, - uniforms: { map: mapTex, uTime, uRevealProgress, uFlip, uIsGameRunning } + uniforms: { map: mapTex, uTime, uRevealProgress, uFlip, uFlipY, uIsGameRunning } } }