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 000000000..5dd756a4a --- /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 000000000..5fd929c92 --- /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" +``` 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 000000000..e6a100b26 --- /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 000000000..db6771fd5 --- /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/next.config.ts b/next.config.ts index 275ff4e7b..f4619da59 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 cfd9957a0..e366ac467 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", @@ -35,8 +33,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 +46,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 +75,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 139a74c66..8c2f9e700 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) @@ -80,12 +74,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 +113,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 +324,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 +941,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==} @@ -1217,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'} @@ -1246,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==} @@ -1729,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: @@ -1948,9 +1897,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 +2097,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 +2117,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 +2270,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 +2293,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 +2362,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 +2643,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 +2677,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 +2833,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 +2876,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 +2899,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 +3104,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 +3136,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 +3433,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 +3465,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 +3490,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 +3562,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 +3621,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 +3649,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 +3799,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 +3827,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 +3920,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 +4253,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 +4369,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 +4445,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 +4509,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 +4527,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 +4545,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 +4619,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 +4679,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 +4741,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 +4771,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 +4797,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 +4996,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 +5008,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 +5108,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==} @@ -5456,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==} @@ -5504,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==} @@ -5684,10 +5353,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 +5878,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': @@ -6466,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': @@ -6505,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)': @@ -6995,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 @@ -7217,15 +6822,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 +7004,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 +7019,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 +7218,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 +7251,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 +7328,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 +7575,6 @@ snapshots: emoji-regex@9.2.2: {} - emojis-list@3.0.0: {} - emulators@8.3.9: {} engine.io-client@6.6.3: @@ -8174,8 +7683,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 +7934,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 +8008,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 +8025,6 @@ snapshots: eventemitter3@5.0.1: {} - events@3.3.0: {} - ext@1.7.0: dependencies: type: 2.7.3 @@ -8733,8 +8231,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 +8273,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 +8553,6 @@ snapshots: is-yarn-global@0.3.0: {} - isarray@0.0.1: {} - isarray@1.0.0: {} isarray@2.0.5: {} @@ -9170,12 +8593,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 +8622,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 +8705,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 +8756,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 +8847,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 +9047,6 @@ snapshots: ms@2.1.3: {} - murmurhash-js@1.0.0: {} - mux-embed@5.8.1: {} mz@2.7.0: @@ -9674,8 +9067,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 +9157,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 +9419,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 +9531,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 +9613,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 +9713,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 +9723,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 +9750,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 +9886,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 +9973,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 +10040,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 +10088,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 +10116,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 +10348,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 +10356,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 +10458,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: {} @@ -11218,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): @@ -11241,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-board/button.tsx b/src/components/arcade-board/button.tsx index 53ba247ed..35be71cc9 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/coffee-steam.tsx b/src/components/arcade-board/coffee-steam.tsx index 48f7842e4..e74b57e55 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/components/arcade-board/stick.tsx b/src/components/arcade-board/stick.tsx index 16d14a939..337c45f47 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/arcade-game/entities/grid.tsx b/src/components/arcade-game/entities/grid.tsx deleted file mode 100644 index 7e5e539ba..000000000 --- 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() - 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/index.tsx b/src/components/arcade-game/index.tsx index 3e1a8ac27..2008aba90 100644 --- a/src/components/arcade-game/index.tsx +++ b/src/components/arcade-game/index.tsx @@ -1,19 +1,18 @@ 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 { + 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" @@ -23,10 +22,14 @@ import { Skybox } from "./skybox" interface arcadeGameProps { visible: boolean - screenMaterial: ShaderMaterial + screenUniforms: { uIsGameRunning: { value: number } } } -export const ArcadeGame = ({ visible, screenMaterial }: arcadeGameProps) => { +// 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) const gameOver = useGame((s) => s.gameOver) @@ -41,6 +44,11 @@ export const ArcadeGame = ({ visible, screenMaterial }: 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) { @@ -52,32 +60,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") { @@ -151,72 +155,46 @@ export const ArcadeGame = ({ visible, screenMaterial }: 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-game/lib/uniforms.ts b/src/components/arcade-game/lib/uniforms.ts deleted file mode 100644 index 8f0eb56b5..000000000 --- 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/arcade-screen/arcade-ui-components/arcade-featured.tsx b/src/components/arcade-screen/arcade-ui-components/arcade-featured.tsx deleted file mode 100644 index bae2ed446..000000000 --- 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 bf7c9d29d..000000000 --- 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 51876ad4c..000000000 --- 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 37e20faa9..000000000 --- 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 1b44e4784..000000000 --- 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 000000000..8a27dc840 --- /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 000000000..a721d2996 --- /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 000000000..2caf812ca --- /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 000000000..d1d1b92d7 --- /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 000000000..339455e4f --- /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 000000000..0c0e9d96a --- /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 000000000..4d880cabe --- /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 000000000..f29bc1a65 --- /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 000000000..218c40f7d --- /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 000000000..17756bf6f --- /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 000000000..fe06a262f --- /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 a397f0f59..6b97653ce 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, @@ -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" @@ -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 @@ -83,38 +86,39 @@ export const ArcadeScreen = () => { if (!arcadeScreen) return videoTexture.flipY = false + videoTexture.colorSpace = "srgb" if (!hasVisitedArcade) { if (isLabRoute) { - screenMaterial.uniforms.map.value = bootTexture - screenMaterial.uniforms.uRevealProgress = { value: 0.0 } + screenUniforms.map.value = bootTexture + screenUniforms.uFlipY.value = 0 + 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 + screenUniforms.uFlipY.value = 1 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.uFlipY.value = 0 + 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.uFlipY.value = 1 + screenUniforms.uFlip.value = isInGame ? 1 : 0 } arcadeScreen.material = screenMaterial @@ -126,13 +130,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(() => { @@ -171,13 +174,13 @@ 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 aafc2757e..000000000 --- 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/basketball/hoop-minigame.tsx b/src/components/basketball/hoop-minigame.tsx index e4dc0c8fa..eb9f0af55 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/basketball/net.tsx b/src/components/basketball/net.tsx index d321af6e0..c25d2b3b6 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/components/blog-door/index.tsx b/src/components/blog-door/index.tsx index 7e6717205..08054ace0 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/camera/camera-controller.tsx b/src/components/camera/camera-controller.tsx index 0d455ad5d..18c208bc6 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 793ca9bb2..f23ccb114 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/characters/character-instancer.tsx b/src/components/characters/character-instancer.tsx index f6a7e2ea3..9375e4e76 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 9cb8e25ce..1b4379e2e 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 7fc2220a4..5511bd0c5 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 7d2866078..9ed8d42b6 100644 --- a/src/components/christmas-tree/client.tsx +++ b/src/components/christmas-tree/client.tsx @@ -1,7 +1,6 @@ -import { MeshDiscardMaterial, useTexture } from "@react-three/drei" -import { useFrame } from "@react-three/fiber" -import { useCallback, useEffect, useRef } from "react" -import { Color, Mesh, ShaderMaterial, Vector3 } from "three" +import { MeshDiscardMaterial } from "@/components/mesh-discard-material" +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" @@ -11,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"), @@ -30,6 +26,14 @@ const INTENSITIES = { star: 16 } +// 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() const { handleMute, music } = useSiteAudio() @@ -39,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 @@ -57,156 +54,41 @@ 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) - - 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 - } + // 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] } - } - }) - }, [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 - } - }) - - 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) - 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 + if (isStar) { + child.material.uniforms.uColor.value = COLORS.star + child.material.uniforms.emissive.value = COLORS.star + child.material.uniforms.ornamentBaseIntensity.value = INTENSITIES.star + } } }) - }) + }, [scene]) useEffect(() => { if (services.pot) services.pot.visible = false diff --git a/src/components/clock/index.tsx b/src/components/clock/index.tsx index b6226c98d..d60ade51e 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/contact/contact-scene.tsx b/src/components/contact/contact-scene.tsx index 17cd3f43b..5a2552e9c 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/doom-js/crt-mesh.tsx b/src/components/doom-js/crt-mesh.tsx index bf19f0cbb..63c6f10dc 100644 --- a/src/components/doom-js/crt-mesh.tsx +++ b/src/components/doom-js/crt-mesh.tsx @@ -1,125 +1,157 @@ "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, + time +} from "three/tsl" -import { useFrameCallback } from "@/hooks/use-pausable-time" +const createCRTMaterial = () => { + const uTexture = tslTexture(new Texture()) + const uTime = time + 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() -// 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; - // 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 = 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 + .sample(distortedUv.sub(distFromCenter.mul(aberrationAmount))) + .r + const g = uTexture.sample(distortedUv).g + const b = uTexture + .sample(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 +159,17 @@ 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 - useFrameCallback((state) => { - if (materialRef.current) { - materialRef.current.uniforms.uTime.value = state.clock.getElapsedTime() - } - }) + const { material, uniforms } = useMemo(() => createCRTMaterial(), []) + + useEffect(() => { + uniforms.uTexture.value = texture + }, [texture, uniforms]) return ( - + ) } diff --git a/src/components/inspectables/inspectable-dragger.tsx b/src/components/inspectables/inspectable-dragger.tsx index eed976049..bccde3219 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 26256ba00..1ff05b2d2 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" @@ -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 bdd753c21..25d7dbc0a 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([ @@ -105,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(() => { @@ -116,8 +126,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) { @@ -144,8 +156,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 } @@ -169,7 +181,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) @@ -214,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 @@ -232,8 +257,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 } } } @@ -330,20 +354,8 @@ export const Lamp = memo(function LampInner() { /> + {lamp && } - - - {/* @ts-ignore */} - - {/* @ts-ignore */} - - - {lamp && } - ) }) diff --git a/src/components/loading/loading-scene/index.tsx b/src/components/loading/loading-scene/index.tsx index 43d978b3f..4744a2ca4 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" @@ -26,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 @@ -84,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 @@ -99,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(256, 256, { magFilter: NearestFilter, minFilter: NearestFilter, type: HalfFloatType @@ -124,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]) @@ -157,80 +132,58 @@ 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]) 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) + // 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 + 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 @@ -239,7 +192,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 } @@ -258,6 +210,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 @@ -272,17 +226,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(() => { @@ -292,8 +254,6 @@ function LoadingScene({ modelUrl }: { modelUrl: string }) { return oc }, []) - // const [handlePointerMove, lerpMouseFloor, vRefsFloor] = useLerpMouse() - const vRefsFloor = useMemo( () => ({ uv: new Vector2(0, 0), @@ -304,28 +264,38 @@ 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) + } + + // Skip flow simulation during screen reveal — flow fades out anyway + if (uScreenReveal.current > 0) return - const renderFlow = (gl: WebGLRenderer, 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) @@ -343,27 +313,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/locked-door/index.tsx b/src/components/locked-door/index.tsx index 1dd08c6c8..384adf5b9 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 dfbebe0f2..bbc7bcb5c 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, @@ -52,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) => { @@ -104,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]) { @@ -120,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]) { @@ -135,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] = {} } @@ -150,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]) { @@ -175,7 +180,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 = () => { @@ -183,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/map/index.tsx b/src/components/map/index.tsx index df190b7a6..142c116d5 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 } @@ -186,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 7f5d29e5e..507cdee38 100644 --- a/src/components/map/use-frame-loop.ts +++ b/src/components/map/use-frame-loop.ts @@ -1,23 +1,19 @@ 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 + globalUniforms.uTime.value += delta + globalUniforms.inspectingEnabled.value = inspectingEnabled.current ? 1 : 0 + globalUniforms.fadeFactor.value = fadeFactor.current.get() - material.uniforms.inspectingEnabled.value = inspectingEnabled.current - 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/components/mesh-discard-material.tsx b/src/components/mesh-discard-material.tsx new file mode 100644 index 000000000..cf8aaab48 --- /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/outdoor-cars/index.tsx b/src/components/outdoor-cars/index.tsx index 4f0a0480d..4c269f40d 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 52c7c1de2..1c20d667c 100644 --- a/src/components/pets/index.tsx +++ b/src/components/pets/index.tsx @@ -1,13 +1,10 @@ -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" import * as THREE from "three" -import { Color } from "three" import { GLTF } from "three/examples/jsm/Addons.js" import { useCurrentScene } from "@/hooks/use-current-scene" @@ -122,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 03625b411..7ac23fc5d 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 { @@ -34,7 +33,10 @@ const Inner = ({ const firstRender = useRef(true) const { isMobile } = useDeviceDetect() - const material = useMemo(() => createPostProcessingMaterial(), []) + const { material, uniforms } = useMemo( + () => createPostProcessingMaterial(), + [] + ) useEffect(() => { revealOpacityMaterials.add(material) @@ -112,49 +114,46 @@ 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 } }) - 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() - - material.uniforms.resolution.value.set(screenWidth, screenHeight) - material.uniforms.uPixelRatio.value = Math.min(window.devicePixelRatio, 2) - - material.uniforms.uActiveBloom.value = isMobile ? 0 : 1 - - material.uniforms.uMainTexture.value = mainTexture - material.uniforms.uDepthTexture.value = depthTexture - - return () => controller.abort() + uniforms.uPixelRatio.value = Math.min(window.devicePixelRatio, 2) + uniforms.uActiveBloom.value = isMobile ? 0 : 1 + uniforms.uMainTexture.value = mainTexture + uniforms.uDepthTexture.value = depthTexture // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mainTexture, depthTexture, isMobile, screenWidth, screenHeight]) - - useFrameCallback((_, __, elapsedTime) => { - material.uniforms.uTime.value = elapsedTime + }, [mainTexture, depthTexture, isMobile]) + + useFrameCallback(({ size }) => { + // 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 007197e10..6e6385cae 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, @@ -63,24 +63,62 @@ 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(), []) 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 }) + const compiledRef = useRef(false) + const compilingRef = useRef(false) - 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 + // 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/routing-element/frag.glsl b/src/components/routing-element/frag.glsl deleted file mode 100644 index 1175a57f9..000000000 --- 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-arrow.tsx b/src/components/routing-element/routing-arrow.tsx index 042abace2..972361991 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-element.tsx b/src/components/routing-element/routing-element.tsx index 32c7c9f44..529ededc6 100644 --- a/src/components/routing-element/routing-element.tsx +++ b/src/components/routing-element/routing-element.tsx @@ -1,10 +1,28 @@ -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, ShaderMaterial } from "three" +import { Mesh } from "three" +import { NodeMaterial } from "three/webgpu" +import { + Fn, + float, + vec3, + uniform, + uv, + dFdx, + dFdy, + sqrt, + min, + max, + step, + smoothstep, + fract, + mix, + screenUV, + viewportSize +} 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,82 @@ 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 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 = viewportSize.x.div(viewportSize.y) + vCoords.x.mulAssign(aspectRatio) - return { routingMaterial, updateMaterialResolution } - }, []) + 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)) - const screenWidth = useThree((state) => state.size.width) - const screenHeight = useThree((state) => state.size.height) + // Discard padding area + isPadding.greaterThan(0.5).discard() - updateMaterialResolution(screenWidth, screenHeight) + // 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: { + opacity: uOpacity, + borderPadding: uBorderPadding + } + } + }, []) const router = useRouter() const pathname = usePathname() @@ -254,8 +319,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/routing-plus.tsx b/src/components/routing-element/routing-plus.tsx index c34228af0..ce229836d 100644 --- a/src/components/routing-element/routing-plus.tsx +++ b/src/components/routing-element/routing-plus.tsx @@ -1,66 +1,102 @@ -import { memo, useMemo } from "react" -import { BufferGeometry, Float32BufferAttribute } from "three" +import { useThree } from "@react-three/fiber" +import { memo, useEffect, useMemo, useRef } from "react" +import { + BufferGeometry, + InstancedMesh as InstancedMeshType, + Object3D, + PlaneGeometry, + Vector4 +} from "three" +import { NodeMaterial } from "three/webgpu" +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 RoutingPlusInner = ({ geometry }: { geometry: BufferGeometry }) => { - const pointsGeometry = useMemo(() => { + const meshRef = useRef(null) + const camera = useThree((state) => state.camera) + + const { planeGeo, material, count, positionsArray, uCamQ } = useMemo(() => { if (!geometry.attributes.position || !geometry.attributes.normal) - return null + return { planeGeo: null, material: null, count: 0, positionsArray: null, uCamQ: 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 uCamQ = uniform(new Vector4(0, 0, 0, 1)) - positions.push(x, y, z) - colors.push(...whiteColor) - } + 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) + 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, uCamQ } }, [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; - } + // 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.updateMatrix() + meshRef.current.setMatrixAt(i, dummy.matrix) } - ` - - return pointsGeometry ? ( - - - - - ) : null + + 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 + + return } export const RoutingPlus = memo(RoutingPlusInner) diff --git a/src/components/routing-element/vert.glsl b/src/components/routing-element/vert.glsl deleted file mode 100644 index 977d66a00..000000000 --- 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; -} diff --git a/src/components/scene/index.tsx b/src/components/scene/index.tsx index d4b9673f3..6a1dcdf12 100644 --- a/src/components/scene/index.tsx +++ b/src/components/scene/index.tsx @@ -2,8 +2,10 @@ 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" import ErrorBoundary from "@/components/basketball/error-boundary" import { CameraController } from "@/components/camera/camera-controller" @@ -25,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( () => @@ -59,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() @@ -152,11 +157,25 @@ export const Scene = () => { e.preventDefault() } }} - gl={{ - antialias: false, - alpha: false, - outputColorSpace: THREE.SRGBColorSpace, - toneMapping: THREE.NoToneMapping + 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, + outputType: THREE.HalfFloatType + } as ConstructorParameters[0]) + await renderer.init() + renderer.outputColorSpace = THREE.SRGBColorSpace + renderer.toneMapping = THREE.NoToneMapping + return renderer }} camera={{ fov: 60 }} className={cn( @@ -165,6 +184,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/frag.glsl b/src/components/sparkles/frag.glsl deleted file mode 100644 index de87ad599..000000000 --- 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 8638f6232..f8ae2b01f 100644 --- a/src/components/sparkles/index.tsx +++ b/src/components/sparkles/index.tsx @@ -1,23 +1,29 @@ -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 * 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 @@ -29,32 +35,101 @@ interface SparklesProps { noise?: number | [number, number, number] | THREE.Vector3 | Float32Array } -export const Sparkle = (props: SparklesProps) => { - const ref = useRef(null) - const { fadeFactor } = useFadeAnimation() +// --- Shared sparkle material (one shader compilation for all 10 instances) --- - useFrameCallback(() => { - if (ref.current) { - // @ts-ignore - ref.current.uniforms.fadeFactor.value = fadeFactor.current.get() +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 } }) + return mat +})() + +export const Sparkle = (props: SparklesProps) => { return ( - {/* @ts-ignore */} - + ) } 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/components/sparkles/vert.glsl b/src/components/sparkles/vert.glsl deleted file mode 100644 index 1bca84f05..000000000 --- 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; -} diff --git a/src/components/speaker-hover/index.tsx b/src/components/speaker-hover/index.tsx index 3d380f55b..ddcd84016 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 a5b14f944..d012c31c7 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 ( <> @@ -135,9 +117,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 6086e50b5..000000000 --- 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 3215bb99a..000000000 --- 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 000000000..dac70f92c --- /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/hooks/use-handle-contact.ts b/src/hooks/use-handle-contact.ts index 8326e169c..0c112cd7c 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 { useWebgl } from "@/hooks/use-webgl" 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 = useWebgl() 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/hooks/use-mesh.ts b/src/hooks/use-mesh.ts index 4c47c010e..d610c9f68 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/hooks/use-preload-assets.ts b/src/hooks/use-preload-assets.ts index b46773c49..30e4b2059 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 34616488d..26eabfb8d 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/hooks/use-webgl.ts b/src/hooks/use-webgl.ts deleted file mode 100644 index 177a94cde..000000000 --- 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 -} diff --git a/src/shaders/material-arcade-image/index.ts b/src/shaders/material-arcade-image/index.ts new file mode 100644 index 000000000..0e0476f3d --- /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 000000000..2b111ff13 --- /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 000000000..6cfb1915e --- /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/fragment.glsl b/src/shaders/material-characters/fragment.glsl deleted file mode 100644 index 34aa3cd6b..000000000 --- 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 207aae132..c4527d5f6 100644 --- a/src/shaders/material-characters/index.ts +++ b/src/shaders/material-characters/index.ts @@ -1,18 +1,392 @@ -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) + // 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, uv())) ?? [] + 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 643aea6c4..000000000 --- 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/fragment.glsl b/src/shaders/material-flow/fragment.glsl deleted file mode 100644 index 031deafe5..000000000 --- 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 acab6b4a4..20871630e 100644 --- a/src/shaders/material-flow/index.ts +++ b/src/shaders/material-flow/index.ts @@ -1,18 +1,147 @@ -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, + fract, + dot +} from "three/tsl" + +const FLOW_RESOLUTION = 256 + +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() + + // 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 + const samplePrevFn = /* @__PURE__ */ Fn(([uvCoord]: [any]) => { + const pixel = uvCoord.mul(FLOW_RESOLUTION) + + const p00 = uFeedbackTexture.sample(uvCoord) + const p10 = uFeedbackTexture.sample( + uvCoord.add(vec2(0.0, -1.0).mul(invRes)) + ) + const p01 = uFeedbackTexture.sample( + uvCoord.add(vec2(-1.0, 0.0).mul(invRes)) + ) + const p21 = uFeedbackTexture.sample( + uvCoord.add(vec2(1.0, 0.0).mul(invRes)) + ) + const p12 = uFeedbackTexture.sample( + 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 = 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 = 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 = 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 = 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)) + + // Increment growth + finalSample.g.addAssign(0.02) + + // 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) + 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 = float(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 b075fd9c3..000000000 --- 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-global-shader/fragment.glsl b/src/shaders/material-global-shader/fragment.glsl deleted file mode 100644 index 4270a13fe..000000000 --- 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 ee8ed27d2..d60e7cfc2 100644 --- a/src/shaders/material-global-shader/index.tsx +++ b/src/shaders/material-global-shader/index.tsx @@ -1,12 +1,71 @@ -import { Matrix3, MeshStandardMaterial, Vector3 } from "three" -import { Color, ShaderMaterial } from "three" -import { create } from "zustand" - -import fragmentShader from "./fragment.glsl" -import vertexShader from "./vertex.glsl" +import { + Color, + DataTexture, + Matrix3, + MeshStandardMaterial, + RGBAFormat, + UnsignedByteType, + 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, + time, + smoothstep, + sin, + abs +} from "three/tsl" +import { basicLight } from "@/shaders/utils/basic-light" export const GLOBAL_SHADER_MATERIAL_NAME = "global-shader-material" +// --- 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) +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?: { @@ -20,150 +79,467 @@ export const createGlobalShaderMaterial = ( CLOUDS?: boolean DAYLIGHT?: boolean IS_LOBO_MARINO?: boolean + ORNAMENT?: boolean + ORNAMENT_STAR?: boolean } ) => { 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 } - } - - if (defines?.LIGHT) { - uniforms["lightDirection"] = { value: lightDirection } - } - - if (defines?.BASKETBALL) { - uniforms["lightDirection"] = { value: lightDirection } - uniforms["backLightDirection"] = { value: new Vector3(0, 0, 1) } - } - - if (defines?.MATCAP) { - uniforms["matcap"] = { value: null } - uniforms["glassMatcap"] = { value: false } - } - - if (defines?.DAYLIGHT) { - uniforms["daylight"] = { value: true } - } - - 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 - }) + // 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) + const isOrnament = Boolean(defines?.ORNAMENT) + const isOrnamentStar = Boolean(defines?.ORNAMENT_STAR) - material.needsUpdate = true - material.customProgramCacheKey = () => GLOBAL_SHADER_MATERIAL_NAME + // --- TSL Uniform Nodes --- - useCustomShaderMaterial.getState().addMaterial(material) + const uColor = uniform(emissiveColor) + const uBaseColor = uniform(baseColorVal) + 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(BLANK_TEXTURE) + const uLightMapIntensity = uniform(0) + const uAoMap = texture(BLANK_TEXTURE) + const uAoMapIntensity = uniform(0) + const uOpacity = uniform(baseOpacity) + const uTime = sharedUTime + 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) + const uFogColor = sharedFogColor + const uFogDensity = sharedFogDensity + const uFogDepth = sharedFogDepth + const uGlassReflex = texture(BLANK_TEXTURE) + const uInspectingEnabled = sharedInspectingEnabled + const uInspectingFactor = uniform(0) + const uFadeFactor = sharedFadeFactor + const uLampLightmap = texture(BLANK_TEXTURE) + const uLightLampEnabled = uniform(0) - baseMaterial.dispose() + // 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 - return material -} + if (isGodray) { + uGodrayOpacity = uniform(0) + uGodrayDensity = uniform(0.75) + } -interface CustomShaderMaterialStore { - /** - * Will not cause re-renders to use this object - */ - materialsRef: Record - addMaterial: (material: ShaderMaterial) => void - removeMaterial: (id: number) => void -} + if (isLight || isBasketball) { + uLightDirection = uniform(lightDir || new Vector3(0, 1, 0)) + } + + if (isBasketball) { + uBackLightDirection = uniform(new Vector3(0, 0, 1)) + } + + if (isMatcap) { + uMatcapTex = texture(BLANK_TEXTURE) + uGlassMatcap = uniform(0) + } + + if (isDaylight) { + 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() + material.name = GLOBAL_SHADER_MATERIAL_NAME + 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 = (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 --- + + const result = Fn(() => { + const normalizedNormal = normalView + const viewDir = normalize(positionView.negate()) + const oneMinusFadeFactor = float(1.0).sub(uFadeFactor) + const isInspectionMode = float(step(float(0.001), uInspectingFactor)) + const shouldFadeF = float(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.sample(mapUv) + } else { + mapSample = vec4(1.0, 1.0, 1.0, 1.0) + } + + if (isClouds) { + mapSample = uMap.sample(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.sample(vUv2).rgb, + uLampLightmap.sample(vUv2).rgb, + uLightLampEnabled + ) + + const irradiance = color.toVar() + + // --- Emissive --- + + if (useEmissive || useEmissiveMap) { + const ei = uEmissiveIntensity + .mul(mix(float(1.0), oneMinusFadeFactor, shouldFadeF)) + .toVar() + + // Ornament cycling: 4-phase sine transition computed on GPU + if (isOrnament) { + 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) + 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 = time + const starOsc = abs(sin(t.mul(2.5))) + ei.assign(uOrnamentBaseIntensity!.mul(float(1.0).add(starOsc))) + } + + if (useEmissive) { + irradiance.addAssign(uEmissive.mul(ei)) + } + + if (useEmissiveMap) { + irradiance.mulAssign(uEmissiveMap.sample(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!.sample(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.sample(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 --- -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] - } - }) -) + const opacityResult = uOpacity.toVar() + + if (isTransparent) { + opacityResult.mulAssign(mapSample.a) + } + + if (useAlphaMap) { + 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) + } + + // 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.sample(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, + alphaMapScrollSpeed: uAlphaMapScrollSpeed, + alphaMapRepeat: uAlphaMapRepeat, + 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! + } + + if (isOrnament) { + uniformsCompat.ornamentPhase = uOrnamentPhase! + uniformsCompat.ornamentBaseIntensity = uOrnamentBaseIntensity! + } + + if (isOrnamentStar) { + uniformsCompat.ornamentBaseIntensity = uOrnamentBaseIntensity! + } + + ;(material as any).uniforms = uniformsCompat + + material.needsUpdate = true + + baseMaterial.dispose() + + return material as any +} diff --git a/src/shaders/material-global-shader/vertex.glsl b/src/shaders/material-global-shader/vertex.glsl deleted file mode 100644 index d006e66db..000000000 --- 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; -} diff --git a/src/shaders/material-net/fragment.glsl b/src/shaders/material-net/fragment.glsl deleted file mode 100644 index 44658bd8f..000000000 --- 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 3a10823b7..9bad31bf9 100644 --- a/src/shaders/material-net/index.ts +++ b/src/shaders/material-net/index.ts @@ -1,34 +1,58 @@ -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 = (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)) + ) + const offset = tDisplacement.sample(dispUv).xzy + const pos = positionLocal.toVar() + pos.addAssign(offset.mul(uOffsetScale)) + return pos + })() + + // Fragment: sample diffuse texture + const texSample = tMap.sample(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 06ad0dbd9..000000000 --- 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); -} diff --git a/src/shaders/material-not-found/fragment.glsl b/src/shaders/material-not-found/fragment.glsl deleted file mode 100644 index 0b5ca55fc..000000000 --- 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 c169c8eeb..6ee680104 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 = float(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) + + // 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) + 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) + ) + + // 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 2e26c4569..000000000 --- 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); -} diff --git a/src/shaders/material-postprocessing/fragment.glsl b/src/shaders/material-postprocessing/fragment.glsl deleted file mode 100644 index e8a024f91..000000000 --- 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 874f068d5..1341081d9 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, + screenUV, + 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 = 16 +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 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) + 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 = screenUV + + // 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.sample(vUv) + const color = tonemapFn(baseColorSample.rgb).toVar() + + // Alpha / reveal logic (branchless) + 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.sample(pixelatedUvEighth) + const baseBrightness = dot(tonemapFn(basePixelatedSample.rgb), LUMINANCE_FACTORS) + // revealHides = 1 when reveal is active AND brightness < reveal threshold + 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 = 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() + 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 + .sample(pixelatedUv.add(sampleOffset).add(invResolution)) + .rgb + const brightness = dot(sampleColor, LUMINANCE_FACTORS) + const shouldAdd = float(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(max(color, vec3(0.0, 0.0, 0.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, + 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 4519f65cc..000000000 --- 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); -} diff --git a/src/shaders/material-screen/fragment.glsl b/src/shaders/material-screen/fragment.glsl deleted file mode 100644 index 21ac7fcfc..000000000 --- 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 50fc7ec58..c6ebbbd47 100644 --- a/src/shaders/material-screen/index.ts +++ b/src/shaders/material-screen/index.ts @@ -1,18 +1,221 @@ -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 uFlipY = uniform(0.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 for render target textures (WebGPU RT has Y=0 at top vs WebGL Y=0 at bottom) + remappedUv.y.assign( + 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) + const centeredUv = remappedUv.sub(0.5) + + const relSquare = abs(centeredUv.sub(vec2(-0.01, -0.4))) + const inSquare = float(1.0) + .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(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 = float(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 = float(step(0.0, remappedUv.x)).mul( + float(step(0.0, float(1.0).sub(remappedUv.x))) + ) + 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.sample(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 = float(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 = float(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, uFlipY, uIsGameRunning } + } +} diff --git a/src/shaders/material-screen/vertex.glsl b/src/shaders/material-screen/vertex.glsl deleted file mode 100644 index 1b56d4e89..000000000 --- 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 -} diff --git a/src/shaders/material-solid-reveal/fragment.glsl b/src/shaders/material-solid-reveal/fragment.glsl deleted file mode 100644 index ac33bb840..000000000 --- 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 4f6e85f35..68319b251 100644 --- a/src/shaders/material-solid-reveal/index.ts +++ b/src/shaders/material-solid-reveal/index.ts @@ -1,20 +1,134 @@ -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, + dot, + time +} from "three/tsl" + +export const createSolidRevealMaterial = () => { + const uTime = time + 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() + + // 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( + 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) + + // High-freq hash noise for voxel grid (reveal threshold, flow SDF modulation) + const noiseSmall = hashNoise3d(voxelCenter.mul(20.0)) + + // 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))) + ) + + // 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( + 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.sample(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 + // uTime uses TSL `time` singleton — auto-updates per render, no CPU pumping needed + ;(material as any).uniforms = { + 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 9c529d97d..000000000 --- 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); -} diff --git a/src/shaders/material-steam/fragment.glsl b/src/shaders/material-steam/fragment.glsl deleted file mode 100644 index 7fb41c41e..000000000 --- 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/index.ts b/src/shaders/material-steam/index.ts index 7be66a9ea..c2aea1aee 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.sample(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.sample(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.sample(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 } } +} diff --git a/src/shaders/material-steam/vertex.glsl b/src/shaders/material-steam/vertex.glsl deleted file mode 100644 index 7765b616a..000000000 --- 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 diff --git a/src/shaders/utils/basic-light.glsl b/src/shaders/utils/basic-light.glsl deleted file mode 100644 index c6044954f..000000000 --- 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/basic-light.ts b/src/shaders/utils/basic-light.ts new file mode 100644 index 000000000..8f305875f --- /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.glsl b/src/shaders/utils/value-remap.glsl deleted file mode 100644 index c600d70a6..000000000 --- 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/shaders/utils/value-remap.ts b/src/shaders/utils/value-remap.ts new file mode 100644 index 000000000..e5cf8c259 --- /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)) + } +) diff --git a/src/store/arcade-store.ts b/src/store/arcade-store.ts index e56777855..061819706 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 diff --git a/src/workers/loading-worker.tsx b/src/workers/loading-worker.tsx index 597697ca4..e6756f89b 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) + } } })