diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0cc39849..d70741e9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,5 @@ import type { Metadata } from 'next'; import './globals.css'; -import { Providers } from '@/components/Providers'; export const metadata: Metadata = { title: 'VOIDSTRIKE - Browser-Based RTS', @@ -24,9 +23,7 @@ export default function RootLayout({ /> - - {children} - + {children} ); diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx deleted file mode 100644 index a74efdfb..00000000 --- a/src/components/Providers.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use client'; - -import { ReactNode } from 'react'; - -interface ProvidersProps { - children: ReactNode; -} - -export function Providers({ children }: ProvidersProps) { - return <>{children}; -} diff --git a/src/components/game/LoadingScreen.tsx b/src/components/game/LoadingScreen.tsx index 9e2c05d5..dfa812fe 100644 --- a/src/components/game/LoadingScreen.tsx +++ b/src/components/game/LoadingScreen.tsx @@ -6,6 +6,7 @@ import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'; +import { NOISE_LIBRARY } from '@/rendering/shaders/noise.glsl'; interface LoadingScreenProps { progress: number; @@ -40,64 +41,7 @@ const VoidNebulaShader = { uniform vec2 uMouse; varying vec2 vUv; - vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } - vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } - vec4 permute(vec4 x) { return mod289(((x*34.0)+1.0)*x); } - vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } - - float snoise(vec3 v) { - const vec2 C = vec2(1.0/6.0, 1.0/3.0); - const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); - vec3 i = floor(v + dot(v, C.yyy)); - vec3 x0 = v - i + dot(i, C.xxx); - vec3 g = step(x0.yzx, x0.xyz); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - vec3 x1 = x0 - i1 + C.xxx; - vec3 x2 = x0 - i2 + C.yyy; - vec3 x3 = x0 - D.yyy; - i = mod289(i); - vec4 p = permute(permute(permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) - + i.y + vec4(0.0, i1.y, i2.y, 1.0)) - + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - float n_ = 0.142857142857; - vec3 ns = n_ * D.wyz - D.xzx; - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - vec4 x = x_ *ns.x + ns.yyyy; - vec4 y = y_ *ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - vec4 s0 = floor(b0)*2.0 + 1.0; - vec4 s1 = floor(b1)*2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0)); - vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww; - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3))); - p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; - vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); - m = m * m; - return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3))); - } - - float fbm(vec3 p) { - float value = 0.0; - float amplitude = 0.5; - for (int i = 0; i < 6; i++) { - value += amplitude * snoise(p); - amplitude *= 0.5; - p *= 2.0; - } - return value; - } + ${NOISE_LIBRARY} void main() { vec2 uv = vUv; diff --git a/src/components/home/HomeBackground.tsx b/src/components/home/HomeBackground.tsx index a339b813..d1fa81cd 100644 --- a/src/components/home/HomeBackground.tsx +++ b/src/components/home/HomeBackground.tsx @@ -6,6 +6,7 @@ import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'; +import { NOISE_LIBRARY } from '@/rendering/shaders/noise.glsl'; // Custom void nebula shader const VoidNebulaShader = { @@ -33,69 +34,7 @@ const VoidNebulaShader = { uniform vec3 uColor3; varying vec2 vUv; - // Simplex noise functions - vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } - vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } - vec4 permute(vec4 x) { return mod289(((x*34.0)+1.0)*x); } - vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } - - float snoise(vec3 v) { - const vec2 C = vec2(1.0/6.0, 1.0/3.0); - const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); - vec3 i = floor(v + dot(v, C.yyy)); - vec3 x0 = v - i + dot(i, C.xxx); - vec3 g = step(x0.yzx, x0.xyz); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - vec3 x1 = x0 - i1 + C.xxx; - vec3 x2 = x0 - i2 + C.yyy; - vec3 x3 = x0 - D.yyy; - i = mod289(i); - vec4 p = permute(permute(permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) - + i.y + vec4(0.0, i1.y, i2.y, 1.0)) - + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - float n_ = 0.142857142857; - vec3 ns = n_ * D.wyz - D.xzx; - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - vec4 x = x_ *ns.x + ns.yyyy; - vec4 y = y_ *ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - vec4 s0 = floor(b0)*2.0 + 1.0; - vec4 s1 = floor(b1)*2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0)); - vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww; - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3))); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); - m = m * m; - return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3))); - } - - float fbm(vec3 p) { - float value = 0.0; - float amplitude = 0.5; - float frequency = 1.0; - for (int i = 0; i < 6; i++) { - value += amplitude * snoise(p * frequency); - amplitude *= 0.5; - frequency *= 2.0; - } - return value; - } + ${NOISE_LIBRARY} void main() { vec2 uv = vUv; diff --git a/src/engine/ai/FormationControl.ts b/src/engine/ai/FormationControl.ts index f6c9eed4..6584adcb 100644 --- a/src/engine/ai/FormationControl.ts +++ b/src/engine/ai/FormationControl.ts @@ -219,7 +219,6 @@ export class FormationControl { const arcSlots = this.calculateArcPositions( group.center, facing, - enemyCenter, meleeUnits.length, 4 // Distance from center to front ); @@ -238,7 +237,6 @@ export class FormationControl { const rangedArcSlots = this.calculateArcPositions( group.center, facing, - enemyCenter, rangedUnits.length, -this.config.rangedOffset // Behind center ); @@ -257,7 +255,6 @@ export class FormationControl { const siegeSlots = this.calculateArcPositions( group.center, facing, - enemyCenter, siegeUnits.length, -this.config.rangedOffset * 2 ); @@ -301,7 +298,6 @@ export class FormationControl { private calculateArcPositions( center: { x: number; y: number }, facing: { x: number; y: number }, - enemyCenter: { x: number; y: number }, count: number, distanceOffset: number ): Array<{ x: number; y: number }> { diff --git a/src/engine/ai/InfluenceMap.ts b/src/engine/ai/InfluenceMap.ts index b2b1b48a..dc7c420b 100644 --- a/src/engine/ai/InfluenceMap.ts +++ b/src/engine/ai/InfluenceMap.ts @@ -313,17 +313,11 @@ export class InfluenceMap { if (players.length < 2) return; for (let i = 0; i < this.threatMap.length; i++) { - let maxInfluence = 0; let totalInfluence = 0; - let _dominantPlayer: string | null = null; - for (const [playerId, data] of players) { + for (const [, data] of players) { const influence = data.grid[i]; totalInfluence += influence; - if (influence > maxInfluence) { - maxInfluence = influence; - _dominantPlayer = playerId; - } } this.threatMap[i] = totalInfluence; diff --git a/src/rendering/InstancedDecorations.ts b/src/rendering/InstancedDecorations.ts index e1b4fc31..e703222d 100644 --- a/src/rendering/InstancedDecorations.ts +++ b/src/rendering/InstancedDecorations.ts @@ -4,6 +4,7 @@ import { MapData, MapDecoration } from '@/data/maps'; import AssetManager from '@/assets/AssetManager'; import { DECORATIONS } from '@/data/rendering.config'; import { TransformUtils } from './shared/InstancedMeshPool'; +import { cloneGeometryForGPU } from './utils/geometryUtils'; // PERF: Shared transform utilities for all decoration classes (avoids duplicate temp objects) const _transformUtils = new TransformUtils(); @@ -33,96 +34,6 @@ const GEOMETRY_QUARANTINE_FRAMES = 4; // Shared frame counter for quarantine timing (updated by updateDecorationFrustum) let _frameCount = 0; -/** - * Clone geometry with completely fresh GPU buffers for WebGPU. - * Creates new TypedArrays for all attributes and index to ensure zero shared state - * with the source geometry. This prevents "setIndexBuffer" crashes when source - * geometry is disposed while clones are still being rendered. - * Also ensures required attributes (like UVs) exist to prevent "Vertex buffer slot" errors. - */ -function cloneGeometryForGPU(source: THREE.BufferGeometry): THREE.BufferGeometry { - const cloned = new THREE.BufferGeometry(); - - // Copy all attributes with fresh TypedArrays (no shared references) - // Skip color attributes - AI models bake AO into vertex colors causing artifacts - for (const name of Object.keys(source.attributes)) { - if (name === 'color' || name.match(/^_?color_?\d+$/i)) { - continue; // Skip vertex colors entirely - } - const srcAttr = source.attributes[name]; - // Create a completely new TypedArray by slicing (creates a copy) - const newArray = srcAttr.array.slice(0); - const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); - newAttr.needsUpdate = true; - cloned.setAttribute(name, newAttr); - } - - // Copy index with fresh TypedArray if present - if (source.index) { - const srcIndex = source.index; - const newIndexArray = srcIndex.array.slice(0); - const newIndex = new THREE.BufferAttribute(newIndexArray, srcIndex.itemSize, srcIndex.normalized); - newIndex.needsUpdate = true; - cloned.setIndex(newIndex); - } - - // Copy morph attributes if present - if (source.morphAttributes) { - for (const name of Object.keys(source.morphAttributes)) { - const srcMorphArray = source.morphAttributes[name]; - cloned.morphAttributes[name] = srcMorphArray.map(srcAttr => { - const newArray = srcAttr.array.slice(0); - const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); - newAttr.needsUpdate = true; - return newAttr; - }); - } - } - - // Copy bounding volumes if computed - if (source.boundingBox) { - cloned.boundingBox = source.boundingBox.clone(); - } - if (source.boundingSphere) { - cloned.boundingSphere = source.boundingSphere.clone(); - } - - // Copy groups - for (const group of source.groups) { - cloned.addGroup(group.start, group.count, group.materialIndex); - } - - // Ensure UV coordinates exist - required by many shaders (slot 1) - // Some models from Tripo/Meshy AI lack UVs, causing "Vertex buffer slot 1" errors - if (!cloned.attributes.uv && cloned.attributes.position) { - const posCount = cloned.attributes.position.count; - const uvArray = new Float32Array(posCount * 2); - // Generate basic UV coords based on position (simple projection) - const pos = cloned.attributes.position; - for (let i = 0; i < posCount; i++) { - uvArray[i * 2] = pos.getX(i) * 0.5 + 0.5; - uvArray[i * 2 + 1] = pos.getZ(i) * 0.5 + 0.5; - } - cloned.setAttribute('uv', new THREE.BufferAttribute(uvArray, 2)); - } - - // ALWAYS recompute smooth vertex normals - AI-generated models often have - // faceted/flat normals that cause triangular artifacts visible at higher resolutions. - // The original GLTF normals may look fine at low res but show per-triangle - // variations at native resolution. - if (cloned.attributes.position) { - // Delete existing normals so computeVertexNormals generates fresh smooth ones - cloned.deleteAttribute('normal'); - cloned.computeVertexNormals(); - } - // Ensure normals are uploaded to GPU - if (cloned.attributes.normal) { - cloned.attributes.normal.needsUpdate = true; - } - - return cloned; -} - /** * Update the shared frustum from camera matrices. * Also stores camera position for distance-based culling. diff --git a/src/rendering/UnitRenderer.ts b/src/rendering/UnitRenderer.ts index 5cb5670f..8cafec73 100644 --- a/src/rendering/UnitRenderer.ts +++ b/src/rendering/UnitRenderer.ts @@ -39,97 +39,16 @@ import { EntityIdTracker, scheduleGeometryDisposal, } from './shared'; +import { cloneGeometryForGPU } from './utils/geometryUtils'; // GPU-driven rendering infrastructure import { CullingService, EntityCategory } from './services/CullingService'; import { LODConfig } from './compute/UnifiedCullingCompute'; import { GPUIndirectRenderer } from './compute/GPUIndirectRenderer'; -/** - * Clone geometry with completely fresh GPU buffers for WebGPU. - * Creates new TypedArrays for all attributes and index to ensure zero shared state - * with the source geometry. This prevents "setIndexBuffer" crashes when source - * geometry is disposed while clones are still being rendered. - * Also ensures required attributes (like UVs) exist to prevent "Vertex buffer slot" errors. - */ -function cloneGeometryForGPU(source: THREE.BufferGeometry): THREE.BufferGeometry { - const cloned = new THREE.BufferGeometry(); - - // Copy all attributes with fresh TypedArrays (no shared references) - // Skip color attributes - AI models bake AO into vertex colors causing artifacts - for (const name of Object.keys(source.attributes)) { - if (name === 'color' || name.match(/^_?color_?\d+$/i)) { - continue; // Skip vertex colors entirely - } - const srcAttr = source.attributes[name]; - // Create a completely new TypedArray by slicing (creates a copy) - const newArray = srcAttr.array.slice(0); - const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); - newAttr.needsUpdate = true; - cloned.setAttribute(name, newAttr); - } - - // Copy index with fresh TypedArray if present - if (source.index) { - const srcIndex = source.index; - const newIndexArray = srcIndex.array.slice(0); - const newIndex = new THREE.BufferAttribute( - newIndexArray, - srcIndex.itemSize, - srcIndex.normalized - ); - newIndex.needsUpdate = true; - cloned.setIndex(newIndex); - } - - // Copy morph attributes if present - if (source.morphAttributes) { - for (const name of Object.keys(source.morphAttributes)) { - const srcMorphArray = source.morphAttributes[name]; - cloned.morphAttributes[name] = srcMorphArray.map((srcAttr) => { - const newArray = srcAttr.array.slice(0); - const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); - newAttr.needsUpdate = true; - return newAttr; - }); - } - } - - // Copy bounding volumes if computed - if (source.boundingBox) { - cloned.boundingBox = source.boundingBox.clone(); - } - if (source.boundingSphere) { - cloned.boundingSphere = source.boundingSphere.clone(); - } - - // Copy groups - for (const group of source.groups) { - cloned.addGroup(group.start, group.count, group.materialIndex); - } - - // Ensure UV coordinates exist - required by many shaders (slot 1) - // Some models from Tripo/Meshy AI lack UVs, causing "Vertex buffer slot 1" errors - if (!cloned.attributes.uv && cloned.attributes.position) { - const posCount = cloned.attributes.position.count; - const uvArray = new Float32Array(posCount * 2); - // Generate basic UV coords based on position (simple projection) - const pos = cloned.attributes.position; - for (let i = 0; i < posCount; i++) { - uvArray[i * 2] = pos.getX(i) * 0.5 + 0.5; - uvArray[i * 2 + 1] = pos.getZ(i) * 0.5 + 0.5; - } - cloned.setAttribute('uv', new THREE.BufferAttribute(uvArray, 2)); - } - - // Ensure normal coordinates exist - required for proper lighting and SSAO - // Some models from Tripo/Meshy AI lack normals, causing GTAO to reconstruct - // normals from depth gradients which creates visible triangular artifacts - if (!cloned.attributes.normal && cloned.attributes.position) { - cloned.computeVertexNormals(); - } - - return cloned; +// Unit geometry cloning - doesn't force recompute normals (units have good normals) +function cloneGeometryForGPUUnit(source: THREE.BufferGeometry): THREE.BufferGeometry { + return cloneGeometryForGPU(source, { recomputeNormals: false }); } // Instance data for a single unit type + player combo at a specific LOD level diff --git a/src/rendering/shaders/noise.glsl.ts b/src/rendering/shaders/noise.glsl.ts new file mode 100644 index 00000000..5bdd7a5c --- /dev/null +++ b/src/rendering/shaders/noise.glsl.ts @@ -0,0 +1,89 @@ +/** + * Shared GLSL noise functions for shader programs. + * These can be concatenated into fragment/vertex shader code. + */ + +/** + * Simplex noise helper functions (mod289, permute, taylorInvSqrt) + */ +export const SIMPLEX_NOISE_HELPERS = ` +vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } +vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } +vec4 permute(vec4 x) { return mod289(((x*34.0)+1.0)*x); } +vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } +`; + +/** + * 3D Simplex noise function + * Returns values in range [-1, 1] + */ +export const SIMPLEX_NOISE_3D = ` +float snoise(vec3 v) { + const vec2 C = vec2(1.0/6.0, 1.0/3.0); + const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); + vec3 i = floor(v + dot(v, C.yyy)); + vec3 x0 = v - i + dot(i, C.xxx); + vec3 g = step(x0.yzx, x0.xyz); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + C.xxx; + vec3 x2 = x0 - i2 + C.yyy; + vec3 x3 = x0 - D.yyy; + i = mod289(i); + vec4 p = permute(permute(permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + float n_ = 0.142857142857; + vec3 ns = n_ * D.wyz - D.xzx; + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ *ns.x + ns.yyyy; + vec4 y = y_ *ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0)*2.0 + 1.0; + vec4 s1 = floor(b1)*2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0)); + vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww; + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3))); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); + m = m * m; + return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3))); +} +`; + +/** + * Fractal Brownian Motion (FBM) using simplex noise + * @param octaves - Number of noise octaves (default 6) + */ +export const FBM_NOISE = ` +float fbm(vec3 p) { + float value = 0.0; + float amplitude = 0.5; + float frequency = 1.0; + for (int i = 0; i < 6; i++) { + value += amplitude * snoise(p * frequency); + amplitude *= 0.5; + frequency *= 2.0; + } + return value; +} +`; + +/** + * Complete noise library - includes all helpers, snoise, and fbm + */ +export const NOISE_LIBRARY = SIMPLEX_NOISE_HELPERS + SIMPLEX_NOISE_3D + FBM_NOISE; diff --git a/src/rendering/tsl/effects/EffectPasses.ts b/src/rendering/tsl/effects/EffectPasses.ts index 7b59afe1..a858429d 100644 --- a/src/rendering/tsl/effects/EffectPasses.ts +++ b/src/rendering/tsl/effects/EffectPasses.ts @@ -723,12 +723,6 @@ export function createFogOfWarPass( const uHeightInfluence = uniform(0.25); const uCoolShift = uniform(new THREE.Color(0.85, 0.9, 1.0)); - // Volumetric fog parameters (for high/ultra quality) - const uVolFogDensity = uniform(0.02); // Base fog density - const uVolFogHeightFalloff = uniform(0.05); // Height-based density falloff - const uVolMaxDistance = uniform(100.0); // Max raymarch distance - const uVolSteps = uniform(32); // Raymarch steps (set by quality) - const uVolFogColor = uniform(new THREE.Color(0.12, 0.14, 0.2)); // Dark blue-gray // Map configuration const uMapDimensions = uniform(new THREE.Vector2(256, 256)); @@ -930,79 +924,6 @@ export function createFogOfWarPass( const rimContribution = uRimColor.mul(rimGlow).mul(isVisible); finalColor.addAssign(rimContribution); - // ============================================ - // VOLUMETRIC FOG - DISABLED - // ============================================ - // Volumetric fog raymarch disabled for fog of war - it causes positional - // shifts due to camera angle bias when sampling visibility along rays. - // AAA RTS games use texture-based fog of war, not volumetric raymarching. - // The surface-based effects (desaturation, darkening, clouds) provide - // the correct visual feedback without coordinate system issues. - const useVolumetric = float(0).greaterThan(1); // Always false - - // Use direct camera position uniform (more reliable than matrix extraction) - const pixelWorldPos = vec3(worldX, worldY, worldZ); - const rayDir = normalize(pixelWorldPos.sub(uCameraPos)); - - // Distance to surface - const surfaceDistance = length(pixelWorldPos.sub(uCameraPos)); - const maxDist = min(surfaceDistance, uVolMaxDistance); - const stepSize = maxDist.div(uVolSteps); - - // Sample visibility at a point along the ray (30% from camera to surface) - // This prevents the southward shift caused by sampling only at the surface, - // which with the RTS camera angle would bias fog toward unexplored areas - const volSamplePoint = uCameraPos.add(rayDir.mul(surfaceDistance.mul(0.3))); - const volVisionU = volSamplePoint.x.div(uMapDimensions.x); - const volVisionV = volSamplePoint.z.div(uMapDimensions.y); - const volVisionUV = clamp(vec2(volVisionU, volVisionV), 0.001, 0.999); - const volVisionSample = visionTextureNode.sample(volVisionUV); - const volExplored = volVisionSample.r; - const volVisible = volVisionSample.g; - const volIsVisible = smoothstep(float(0.4), float(0.6), volVisible); - const volIsExplored = smoothstep(float(0.4), float(0.6), volExplored); - const volIsUnexplored = float(1.0).sub(max(volIsVisible, volIsExplored)); - const volExploredAmount = volIsExplored.mul(float(1.0).sub(volIsVisible)); - - // Base fog density from visibility sampled along the ray - // Unexplored = full fog, explored = partial, visible = none - const baseDensity = volIsUnexplored.add(volExploredAmount.mul(0.4)).mul(uVolFogDensity); - - // Volumetric accumulation - const volTransmittance = float(1.0).toVar(); - const volScatter = vec3(0.0).toVar(); - - // Raymarch loop - ONLY simple math operations (no fbm3, no texture sampling) - const idx = int(0).toVar(); - Loop(64, () => { - If(idx.greaterThanEqual(uVolSteps), () => { - Break(); - }); - - // Position along ray - const t = float(idx).add(0.5).mul(stepSize); - const pos = uCameraPos.add(rayDir.mul(t)); - - // Height-based density falloff (simple exp - no complex functions) - const heightFactor2 = exp(pos.y.negate().mul(uVolFogHeightFalloff)); - const localDensity = baseDensity.mul(heightFactor2); - - // Simple in-scattering (no phase function complexity) - volScatter.addAssign(uVolFogColor.mul(localDensity).mul(stepSize).mul(volTransmittance)); - - // Beer-Lambert absorption - volTransmittance.mulAssign(exp(localDensity.mul(stepSize).negate())); - - idx.addAssign(1); - }); - - // Combine volumetric with scene - const volFogAmount = float(1.0).sub(volTransmittance); - const volResult = mix(finalColor, uVolFogColor.add(volScatter), volFogAmount); - - // Apply volumetric only for high/ultra quality - finalColor.assign(useVolumetric.select(volResult, finalColor)); - // DEBUG: Show world position as color to verify reconstruction // Red = X position, Blue = Z position (normalized to map dimensions) const debugWorldColor = vec3( @@ -1041,12 +962,6 @@ export function createFogOfWarPass( mapDimensions: uMapDimensions, gridDimensions: uGridDimensions, cellSize: uCellSize, - // Volumetric fog uniforms - volFogDensity: uVolFogDensity, - volFogHeightFalloff: uVolFogHeightFalloff, - volMaxDistance: uVolMaxDistance, - volSteps: uVolSteps, - volFogColor: uVolFogColor, }; const setVisionTexture = (tex: THREE.Texture | null) => { @@ -1096,16 +1011,6 @@ export function createFogOfWarPass( if (config.quality !== undefined) { const qualityIndex = ['low', 'medium', 'high', 'ultra'].indexOf(config.quality); uQuality.value = qualityIndex >= 0 ? qualityIndex : 2; - - // Set volumetric raymarch steps based on quality - // high: 32 steps, ultra: 48 steps (low/medium don't use volumetric) - const volStepsMap: Record = { - low: 16, - medium: 24, - high: 32, - ultra: 48, - }; - uVolSteps.value = volStepsMap[config.quality] ?? 32; } if (config.edgeBlurRadius !== undefined) { uEdgeBlurRadius.value = config.edgeBlurRadius; diff --git a/src/rendering/utils/GeometryQuarantine.ts b/src/rendering/utils/GeometryQuarantine.ts new file mode 100644 index 00000000..eecb6a36 --- /dev/null +++ b/src/rendering/utils/GeometryQuarantine.ts @@ -0,0 +1,123 @@ +import * as THREE from 'three'; + +/** + * Geometry disposal quarantine for WebGPU safety. + * + * WebGPU may have 2-3 frames in flight at any time. Disposing geometry + * while the GPU is still rendering it causes "setIndexBuffer" crashes. + * This utility delays disposal until the GPU has finished pending draw commands. + * + * Usage: + * ```ts + * class MyRenderer { + * private quarantine = new GeometryQuarantine(); + * + * update() { + * this.quarantine.processFrame(); + * // ... render logic + * } + * + * replaceGeometry(oldGeom: THREE.BufferGeometry, oldMats: THREE.Material[]) { + * this.quarantine.queue(oldGeom, oldMats); + * } + * + * dispose() { + * this.quarantine.disposeAll(); + * } + * } + * ``` + */ +export class GeometryQuarantine { + private queue: Array<{ + geometry: THREE.BufferGeometry; + materials: THREE.Material[]; + frameQueued: number; + }> = []; + + private frameCount = 0; + + /** Number of frames to wait before disposing (default 4 for safety margin) */ + public quarantineFrames: number; + + constructor(quarantineFrames = 4) { + this.quarantineFrames = quarantineFrames; + } + + /** + * Queue geometry and materials for delayed disposal. + */ + public queueForDisposal( + geometry: THREE.BufferGeometry, + materials: THREE.Material[] = [] + ): void { + this.queue.push({ + geometry, + materials, + frameQueued: this.frameCount, + }); + } + + /** + * Process the quarantine queue - call once per frame. + * Disposes items that have been queued long enough. + */ + public processFrame(): void { + this.frameCount++; + + let writeIndex = 0; + for (let i = 0; i < this.queue.length; i++) { + const entry = this.queue[i]; + const framesInQuarantine = this.frameCount - entry.frameQueued; + + if (framesInQuarantine >= this.quarantineFrames) { + // Safe to dispose now + entry.geometry.dispose(); + for (const material of entry.materials) { + material.dispose(); + } + } else { + // Keep in quarantine + this.queue[writeIndex++] = entry; + } + } + this.queue.length = writeIndex; + } + + /** + * Immediately dispose all queued items (for cleanup on destroy). + */ + public disposeAll(): void { + for (const entry of this.queue) { + entry.geometry.dispose(); + for (const material of entry.materials) { + material.dispose(); + } + } + this.queue = []; + } + + /** + * Get current queue size (for debugging). + */ + public get queueSize(): number { + return this.queue.length; + } +} + +// Shared frame counter for modules that use the global pattern +let _globalFrameCount = 0; + +/** + * Increment the global frame counter. + * Call once per frame before processing any quarantine queues. + */ +export function incrementGlobalFrame(): void { + _globalFrameCount++; +} + +/** + * Get the current global frame count. + */ +export function getGlobalFrameCount(): number { + return _globalFrameCount; +} diff --git a/src/rendering/utils/geometryUtils.ts b/src/rendering/utils/geometryUtils.ts new file mode 100644 index 00000000..c6d5f1fe --- /dev/null +++ b/src/rendering/utils/geometryUtils.ts @@ -0,0 +1,122 @@ +import * as THREE from 'three'; + +export interface CloneGeometryOptions { + /** Skip vertex color attributes (AI models often bake AO into vertex colors causing artifacts) */ + skipVertexColors?: boolean; + /** Always recompute normals from geometry (fixes AI model faceted/flat normals) */ + recomputeNormals?: boolean; + /** Generate UVs if missing (required by many shaders) */ + generateMissingUVs?: boolean; +} + +const DEFAULT_OPTIONS: CloneGeometryOptions = { + skipVertexColors: true, + recomputeNormals: true, + generateMissingUVs: true, +}; + +/** + * Clone geometry with completely fresh GPU buffers for WebGPU. + * Creates new TypedArrays for all attributes and index to ensure zero shared state + * with the source geometry. This prevents "setIndexBuffer" crashes when source + * geometry is disposed while clones are still being rendered. + * + * Also handles common issues with AI-generated models: + * - Missing UV coordinates causing "Vertex buffer slot" errors + * - Faceted/flat normals causing triangular artifacts + * - Vertex colors baking AO causing visual artifacts + */ +export function cloneGeometryForGPU( + source: THREE.BufferGeometry, + options: CloneGeometryOptions = {} +): THREE.BufferGeometry { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const cloned = new THREE.BufferGeometry(); + + // Copy all attributes with fresh TypedArrays (no shared references) + for (const name of Object.keys(source.attributes)) { + // Skip vertex colors if configured (AI models bake AO into vertex colors causing artifacts) + if (opts.skipVertexColors && (name === 'color' || name.match(/^_?color_?\d+$/i))) { + continue; + } + const srcAttr = source.attributes[name]; + // Create a completely new TypedArray by slicing (creates a copy) + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + cloned.setAttribute(name, newAttr); + } + + // Copy index with fresh TypedArray if present + if (source.index) { + const srcIndex = source.index; + const newIndexArray = srcIndex.array.slice(0); + const newIndex = new THREE.BufferAttribute( + newIndexArray, + srcIndex.itemSize, + srcIndex.normalized + ); + newIndex.needsUpdate = true; + cloned.setIndex(newIndex); + } + + // Copy morph attributes if present + if (source.morphAttributes) { + for (const name of Object.keys(source.morphAttributes)) { + const srcMorphArray = source.morphAttributes[name]; + cloned.morphAttributes[name] = srcMorphArray.map((srcAttr) => { + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + return newAttr; + }); + } + } + + // Copy bounding volumes if computed + if (source.boundingBox) { + cloned.boundingBox = source.boundingBox.clone(); + } + if (source.boundingSphere) { + cloned.boundingSphere = source.boundingSphere.clone(); + } + + // Copy groups + for (const group of source.groups) { + cloned.addGroup(group.start, group.count, group.materialIndex); + } + + // Generate UV coordinates if missing - required by many shaders (slot 1) + // Some models from Tripo/Meshy AI lack UVs, causing "Vertex buffer slot 1" errors + if (opts.generateMissingUVs && !cloned.attributes.uv && cloned.attributes.position) { + const posCount = cloned.attributes.position.count; + const uvArray = new Float32Array(posCount * 2); + // Generate basic UV coords based on position (simple projection) + const pos = cloned.attributes.position; + for (let i = 0; i < posCount; i++) { + uvArray[i * 2] = pos.getX(i) * 0.5 + 0.5; + uvArray[i * 2 + 1] = pos.getZ(i) * 0.5 + 0.5; + } + cloned.setAttribute('uv', new THREE.BufferAttribute(uvArray, 2)); + } + + // Handle normals based on options + if (cloned.attributes.position) { + if (opts.recomputeNormals) { + // Always recompute smooth vertex normals - AI-generated models often have + // faceted/flat normals that cause triangular artifacts visible at higher resolutions. + cloned.deleteAttribute('normal'); + cloned.computeVertexNormals(); + } else if (!cloned.attributes.normal) { + // Just add normals if missing + cloned.computeVertexNormals(); + } + } + + // Ensure normals are uploaded to GPU + if (cloned.attributes.normal) { + cloned.attributes.normal.needsUpdate = true; + } + + return cloned; +}