spoint 0.1.629 → 0.1.630

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/app.js CHANGED
@@ -52,7 +52,6 @@ import { createRenderGraph } from './core/RenderGraph.js'
52
52
  import { buildRenderSectionNodes } from './core/RenderGraph.nodes.js'
53
53
  import { buildSSAONodes, installSSAO } from './core/SSAO.js'
54
54
  import { buildSSRNodes, installSSR } from './core/SSR.js'
55
- import { buildHalfResTransparentsNodes, installHalfResTransparents } from './core/HalfResTransparents.js'
56
55
  import { buildBloomNodes, installBloom, registerBloomWebGPU } from './core/Bloom.js'
57
56
  import { buildFSR1Nodes, installFSR1, registerFSR1WebGPU } from './core/FSR1.js'
58
57
  import { installRenderControls, RenderControls } from './core/RenderControls.js'
@@ -535,14 +534,7 @@ function _ensureWeather(tb) {
535
534
  const wcfg = _terrainCfg.weather
536
535
  if (!wcfg || wcfg === false) return null
537
536
  try {
538
- // window.__halfResTransparentsDebug is populated by installHalfResTransparents at module-level
539
- // boot (client/app.js, before this async _buildWorldScenery path ever runs) -- read lazily via
540
- // the window handle rather than a captured module const, since _ensureWeather's own definition
541
- // runs before that module-level install line executes (function bodies don't evaluate until
542
- // called; by call time the install has already happened). See HalfResTransparents.js header.
543
- const hrt = (typeof window !== 'undefined') ? window.__halfResTransparentsDebug : null
544
- const tagHalfRes = hrt ? (obj) => hrt.tagHalfRes(obj) : null
545
- weather = createWeather({ renderer, scene, frame: tb && tb.frame, cfg: wcfg, tagHalfRes })
537
+ weather = createWeather({ renderer, scene, frame: tb && tb.frame, cfg: wcfg })
546
538
  if (window.__app) window.__app.weather = weather
547
539
  } catch (e) { console.error('[weather] init failed:', e?.message || e) }
548
540
  return null
@@ -2702,7 +2694,7 @@ function tickPlayerAnimators(lid, frameDt, isEditor) {
2702
2694
  // window.__renderGraph = live inspection surface (stats()/capture()/disable(id)/toMermaid()).
2703
2695
  // _graphCtx is PERSISTENT so ctx.res carries last-frame resources (the deliberate one-frame
2704
2696
  // near/far lag + shouldRun-skip semantics depend on it).
2705
- const renderGraph = createRenderGraph([...buildRenderSectionNodes(), ...buildSSAONodes(), ...buildBloomNodes(), ...buildSSRNodes(), ...buildHalfResTransparentsNodes(), ...buildFSR1Nodes()])
2697
+ const renderGraph = createRenderGraph([...buildRenderSectionNodes(), ...buildSSAONodes(), ...buildBloomNodes(), ...buildSSRNodes(), ...buildFSR1Nodes()])
2706
2698
  // pm (PlayerManager, declared above) exposes pm.playerMeshes (Map<id, THREE.Group>, real live
2707
2699
  // per-player world positions, local + remote, server-snapshot-reconciled) -- foliage-lod-sync's grass
2708
2700
  // player-bend wiring reads ctx.pm.playerMeshes every frame to feed Grass.js's nearby-bender buffer.
@@ -2732,11 +2724,6 @@ installBloom(_graphCtx, renderer)
2732
2724
  // SSR shares SSAO's G-buffer when available (see SSR.js buildSSRNodes' ssr-compute node), so it
2733
2725
  // pays zero extra scene-render cost beyond SSAO's own existing pass.
2734
2726
  installSSR(_graphCtx, renderer, scene, camera)
2735
- // Half-res transparents/particles GPU resources are built lazily too (RenderControls
2736
- // ('halfResTransparents') gate, same discipline as SSAO/Bloom/SSR above) -- installed once here so
2737
- // ctx.halfResTransparents is populated before the first frame the flag might be on, and so
2738
- // _ensureWeather (below) can tag its particle pools onto the half-res layer as soon as they exist.
2739
- installHalfResTransparents(_graphCtx, renderer, scene, camera)
2740
2727
  // FSR1 GPU resources are built lazily too (RenderControls('fsr1') gate, same discipline as
2741
2728
  // SSAO/Bloom/SSR above) -- installed once here so ctx.fsr1 is populated before the first frame the
2742
2729
  // flag might be on. Only actually runs while the DPR controller has genuinely downscaled (see
@@ -40,7 +40,7 @@ const PRESETS = {
40
40
  vegAllOff: false,
41
41
  vegHideFar: null,
42
42
  grassWind: false,
43
- halfResWater: true,
43
+ halfResWater: false,
44
44
  thc: false,
45
45
  ssao: false,
46
46
  bloom: false,
@@ -60,7 +60,7 @@ const PRESETS = {
60
60
  vegAllOff: false,
61
61
  vegHideFar: null,
62
62
  grassWind: true,
63
- halfResWater: true,
63
+ halfResWater: false,
64
64
  thc: false,
65
65
  ssao: true,
66
66
  bloom: true,
@@ -76,7 +76,7 @@ const PRESETS = {
76
76
  vegAllOff: false,
77
77
  vegHideFar: null,
78
78
  grassWind: true,
79
- halfResWater: true,
79
+ halfResWater: false,
80
80
  thc: false,
81
81
  ssao: true,
82
82
  bloom: true,
@@ -168,16 +168,6 @@ const CONTROLS = [
168
168
  { key: 'bloomBlurPasses', group: 'bloom', type: 'number', default: 1,
169
169
  doc: 'Number of full horizontal+vertical separable-blur passes applied to the bright-pass texture. Higher = smoother/wider glow, more GPU cost.' },
170
170
 
171
- // ---- Half-res transparents/particles (see HalfResTransparents.js -- half-resolution offscreen
172
- // composite of THREE.Layers-tagged transparent/particle content, e.g. Weather.js rain/snow;
173
- // does NOT touch the shared canvas depth contract documented in DepthComposite.js) ----
174
- { key: 'halfResTransparents', group: 'half-res-transparents', type: 'boolean', default: false,
175
- doc: 'Render THREE.Layers-tagged transparent/particle content (see HalfResTransparents.HALFRES_LAYER, currently Weather.js rain/snow particle pools) into a half-resolution offscreen target instead of full-res, composited back with real depth-tested occlusion against the opaque scene. Perf win on overdraw-heavy particle scenes (weather); default off pending a device-tier default decision.' },
176
- { key: 'halfResTransparentsScale', group: 'half-res-transparents', type: 'number', default: 0.5,
177
- doc: 'Render-target resolution scale (fraction of canvas size) for the half-res transparents target. Lower = cheaper, softer/blurrier particle edges.' },
178
- { key: 'halfResTransparentsDepthSigma', group: 'half-res-transparents', type: 'number', default: 2.0,
179
- doc: 'Depth-delta scale (metres) at which the composite bilateral upsample halves a neighbor tap\'s weight (see HalfResTransparents.js _compositeFrag). Lower = more aggressive edge-preservation (sharper depth-discontinuity cutoff, more prone to single-texel dropout on noisy depth); higher = softer, closer to plain bilinear.' },
180
-
181
171
  // ---- FSR1 spatial upscale/sharpen (see FSR1.js -- companion to the DPR controller below: a DPR
182
172
  // drop shrinks the WebGL drawing buffer, which the browser then bilinear-stretches back up
183
173
  // to CSS size; this pass replaces that dumb stretch with an EASU+RCAS upscale+sharpen so a
@@ -207,8 +197,8 @@ const CONTROLS = [
207
197
  doc: 'READ-ONLY mirror of the live VRAM budget tracker (ModelPoolAdapter.getVramStats()), refreshed once per frame: {usedMB, estimatedVramMB, currentRatio, peakRatio, byteBudgetMB, totalBytes, unloadedCount, visibleEntities, invisibleEntities, recentEvents}. recentEvents is a real ring-buffer log (real vram-warning/vram-critical/budget-pressure/budget-relaxed/budget-adjust events the pool actually emitted, not synthetic). window.__vramBudget mirrors the same data plus setBudgetMB()/log() convenience calls.' },
208
198
 
209
199
  // ---- mapspinner-side knobs (read in packages/mapspinner/src/gl-render.js; documented here for discovery) ----
210
- { key: 'halfResWater', group: 'mapspinner-water', type: 'boolean', default: true,
211
- doc: 'mapspinner half-res water pass (near sea level). When active the scene renders into a single-sample VDRS FBO then upscales. Set false to force the scene straight to the MSAA canvas.' },
200
+ { key: 'halfResWater', group: 'mapspinner-water', type: 'boolean', default: false,
201
+ doc: 'mapspinner water-occlusion depth-share pass (near sea level). When active the scene renders into a single-sample VDRS FBO (losing MSAA) then upscales, purely so the water-occlusion depth test has a real depth buffer to blit (the default framebuffer cannot be read via blitFramebuffer). Default off (every quality preset sets this false) since the MSAA loss reads as a visible quality drop across the whole frame, not just water -- set true only to re-enable the occlusion-depth-share mechanism at the cost of full-frame MSAA.' },
212
202
  { key: 'vdrs', group: 'mapspinner-resolution', type: 'boolean', default: false,
213
203
  doc: 'mapspinner explicit viewport dynamic-resolution mode (independent of the half-res-water-forced path): true routes terrain+water into a fixed-size offscreen FBO, renders it at a flexed __vdrsScale<1 sub-viewport, then LINEAR-upscales that sub-rect to the canvas at its actual (unchanged) size -- terrain renders fewer pixels, the canvas/presented frame stays full resolution. Pairs with vdrsScale. Normally driven automatically by the vdrsAuto controller (see resolution group above); settable directly for manual testing.' },
214
204
  { key: 'vdrsUpscaleFsr1', group: 'mapspinner-resolution', type: 'boolean', default: false,
@@ -111,6 +111,25 @@ export function createSharedImpostorMesh(renderer, atlas, dims, opts = {}) {
111
111
  mat.polygonOffsetUnits = -8
112
112
  // Base geo must be zero-area below nearCutoff (near trees draw the full branch mesh instead) or every near tree double-draws; plane is the far LOD.
113
113
  const nearCutoff = opts.nearCutoff
114
+ const IMPOSTOR_DISSOLVE_FADE_BAND_M = 3.0
115
+ if (Number.isFinite(nearCutoff) && nearCutoff > 0) {
116
+ const baseCompile = mat.onBeforeCompile
117
+ mat.onBeforeCompile = (shader, r) => {
118
+ baseCompile?.call(mat, shader, r)
119
+ shader.uniforms.uImpNearCutoff = { value: nearCutoff }
120
+ shader.vertexShader = 'uniform float uImpNearCutoff;\nvarying float vImpCamDist;\n' + shader.vertexShader
121
+ shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
122
+ '#include <begin_vertex>\nvImpCamDist = distance(cameraPosition, (modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz);')
123
+ shader.fragmentShader = 'uniform float uImpNearCutoff;\nvarying float vImpCamDist;\n' + shader.fragmentShader
124
+ shader.fragmentShader = shader.fragmentShader.replace('#include <dithering_fragment>',
125
+ '#include <dithering_fragment>\n' +
126
+ `float _impFade = clamp(abs(vImpCamDist - uImpNearCutoff) / ${IMPOSTOR_DISSOLVE_FADE_BAND_M.toFixed(1)}, 0.0, 1.0);\n` +
127
+ 'float _impDither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n' +
128
+ 'if (_impDither > _impFade) discard;')
129
+ }
130
+ const baseKey = mat.customProgramCacheKey
131
+ mat.customProgramCacheKey = () => baseKey() + '_impfade'
132
+ }
114
133
  let baseGeo
115
134
  if (Number.isFinite(nearCutoff) && nearCutoff > 0) {
116
135
  baseGeo = new THREE.BufferGeometry()
@@ -75,7 +75,16 @@ const _vanMat = new THREE.Matrix4(), _vanProj = new THREE.Matrix4(), _vanFrustum
75
75
  // shared wind uniform (one per veg system); advancing one .value per frame sways all LODs of all species with zero per-instance JS
76
76
  function makeWindUniforms() { return { uVegTime: { value: 0 }, uVegWind: { value: 1 } } }
77
77
 
78
- function applyWind(material, wind) {
78
+ function _dissolveBoundaryDistanceShaderChunk(boundaries) {
79
+ const uniforms = boundaries.map((_, i) => `uniform float uVegLodB${i};`).join('\n')
80
+ const mindist = boundaries.map((_, i) => `_vegDistToB = min(_vegDistToB, abs(_vegCamDist - uVegLodB${i}));`).join('\n ')
81
+ return { uniforms, mindist }
82
+ }
83
+
84
+ const VEG_DISSOLVE_FADE_BAND_M = 3.0
85
+
86
+ function applyWind(material, wind, lodBoundaries) {
87
+ const chunk = lodBoundaries && lodBoundaries.length ? _dissolveBoundaryDistanceShaderChunk(lodBoundaries) : null
79
88
  material.onBeforeCompile = (shader) => {
80
89
  shader.uniforms.uVegTime = wind.uVegTime
81
90
  shader.uniforms.uVegWind = wind.uVegWind
@@ -90,8 +99,23 @@ function applyWind(material, wind) {
90
99
  // per-instance shade: multiply the lit diffuse by the instance tint (brightness variation).
91
100
  shader.fragmentShader = shader.fragmentShader.replace('#include <color_fragment>',
92
101
  '#include <color_fragment>\n diffuseColor.rgb *= tint;')
102
+ if (chunk) {
103
+ lodBoundaries.forEach((d, i) => { shader.uniforms['uVegLodB' + i] = { value: d } })
104
+ shader.vertexShader = chunk.uniforms + '\nvarying float vVegCamDist;\n' + shader.vertexShader
105
+ shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
106
+ '#include <begin_vertex>\nvVegCamDist = distance(cameraPosition, (modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz);')
107
+ shader.fragmentShader = chunk.uniforms + '\nvarying float vVegCamDist;\n' + shader.fragmentShader
108
+ shader.fragmentShader = shader.fragmentShader.replace('#include <dithering_fragment>',
109
+ '#include <dithering_fragment>\n' +
110
+ `float _vegCamDist = vVegCamDist;\n` +
111
+ `float _vegDistToB = 1e9;\n` +
112
+ ` ${chunk.mindist}\n` +
113
+ `float _vegFade = clamp(_vegDistToB / ${VEG_DISSOLVE_FADE_BAND_M.toFixed(1)}, 0.0, 1.0);\n` +
114
+ 'float _vegDither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n' +
115
+ 'if (_vegDither > _vegFade) discard;')
116
+ }
93
117
  }
94
- material.customProgramCacheKey = () => 'vegwind2'
118
+ material.customProgramCacheKey = () => 'vegwind3' + (chunk ? '_fade' + lodBoundaries.join('_') : '')
95
119
  return material
96
120
  }
97
121
 
@@ -286,8 +310,8 @@ export async function createVegetation(opts = {}) {
286
310
  const _treeSph = _treeBox.getBoundingSphere(new THREE.Sphere())
287
311
  branchGeo0.boundingBox = _treeBox.clone(); leafGeo0.boundingBox = _treeBox.clone()
288
312
  branchGeo0.boundingSphere = _treeSph.clone(); leafGeo0.boundingSphere = _treeSph.clone()
289
- const branch = new InstancedMesh2(branchGeo0, applyWind(sp.branchMat, wind), { capacity: INIT_CAP, renderer })
290
- const leaf = new InstancedMesh2(leafGeo0, applyWind(sp.leafMat, wind), { capacity: INIT_CAP, renderer })
313
+ const branch = new InstancedMesh2(branchGeo0, applyWind(sp.branchMat, wind, [D1, D2, D3]), { capacity: INIT_CAP, renderer })
314
+ const leaf = new InstancedMesh2(leafGeo0, applyWind(sp.leafMat, wind, [D1, D2]), { capacity: INIT_CAP, renderer })
291
315
  for (const m of [branch, leaf]) {
292
316
  m.initUniformsPerInstance({ vertex: { windPhase: 'float' }, fragment: { tint: 'float' } })
293
317
  m.perObjectFrustumCulled = true
@@ -243,20 +243,12 @@ const _camPos = new THREE.Vector3(), _camQuat = new THREE.Quaternion()
243
243
 
244
244
  // opts: { renderer, scene, frame (terrain frame, for ground-height sampling -- optional, falls back
245
245
  // to a fixed splash plane if absent), cfg: { type: 'rain'|'snow'|'clear', intensity: 0..1,
246
- // particleCount, farParticleCount, boxRadius, boxHeight, fallSpeed, snowAccumulation },
247
- // tagHalfRes: optional (object) => void -- called once per particle pool right after scene.add(),
248
- // see client/core/HalfResTransparents.js (half-res-transparents-particles-pass). Weather's own
249
- // rain/snow/splash/far-sheet InstancedMesh2 pools are the first (and, at this row's first-slice
250
- // scope, only) opt-in consumer of that half-res compositing pass -- exactly the "particle-heavy
251
- // scenes (e.g. weather rain/snow tier)" case the PRD row names. Optional/no-op when absent so
252
- // Weather.js has zero dependency on HalfResTransparents existing (dual-import-safe, matches every
253
- // other opts.* callback in this module). }
246
+ // particleCount, farParticleCount, boxRadius, boxHeight, fallSpeed, snowAccumulation } }
254
247
  export function createWeather(opts = {}) {
255
248
  const { renderer, scene } = opts
256
249
  if (!renderer || !scene) throw new Error('createWeather: renderer/scene required')
257
250
  const cfg = opts.cfg || {}
258
251
  const frame = opts.frame || null
259
- const tagHalfRes = typeof opts.tagHalfRes === 'function' ? opts.tagHalfRes : null
260
252
 
261
253
  const BOX_RADIUS = Number.isFinite(cfg.boxRadius) ? cfg.boxRadius : 22 // XZ half-extent of the near-tier falling-volume box, camera-centered
262
254
  const BOX_HEIGHT = Number.isFinite(cfg.boxHeight) ? cfg.boxHeight : 18 // Y extent above the camera near-tier particles fall through
@@ -328,7 +320,6 @@ export function createWeather(opts = {}) {
328
320
  im.frustumCulled = false
329
321
  im.visible = false
330
322
  scene.add(im)
331
- if (tagHalfRes) tagHalfRes(im)
332
323
 
333
324
  const geoSplash = makeSplashGeo()
334
325
  const matSplash = makeSplashMaterial()
@@ -338,7 +329,6 @@ export function createWeather(opts = {}) {
338
329
  imSplash.frustumCulled = false
339
330
  imSplash.visible = false
340
331
  scene.add(imSplash)
341
- if (tagHalfRes) tagHalfRes(imSplash)
342
332
 
343
333
  const geoFlake = makeFlakeGeo()
344
334
  const matSnow = makeSnowMaterial()
@@ -347,7 +337,6 @@ export function createWeather(opts = {}) {
347
337
  imSnow.frustumCulled = false
348
338
  imSnow.visible = false
349
339
  scene.add(imSnow)
350
- if (tagHalfRes) tagHalfRes(imSnow)
351
340
 
352
341
  // Far sheet: one pool, re-skinned (material swapped) per type rather than two separate meshes --
353
342
  // only one of rain/snow is ever active at a time (type is a single enum), so there is no benefit to
@@ -362,7 +351,6 @@ export function createWeather(opts = {}) {
362
351
  imFar.frustumCulled = false
363
352
  imFar.visible = false
364
353
  scene.add(imFar)
365
- if (tagHalfRes) tagHalfRes(imFar)
366
354
  let _farGeoIsSnow = false
367
355
 
368
356
  // Snow ground accumulation: a REUSED src/terrain/GrassDecal.js sparse cell store (see file header --
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.629",
3
+ "version": "0.1.630",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,456 +0,0 @@
1
- // HalfResTransparents -- half-resolution transparent/particle compositing pass, gated behind
2
- // RenderControls('halfResTransparents'). Roadmap #37 (half-res-transparents-particles-pass).
3
- //
4
- // WHY: overdraw-heavy transparent content (rain/snow particle volumes, future smoke/muzzle-flash
5
- // sprites) costs the SAME per-pixel fragment-shader work as opaque geometry but contributes far
6
- // less to perceived detail -- a classic half-res-offscreen-composite win (the exact discipline
7
- // SSAO.js/Bloom.js/mapspinner's own half-res-water VDRS path already use in this codebase, see
8
- // AGENTS.md water-depth-share-not-terrain-only). This module applies the same discipline to
9
- // THREE-side transparent/particle objects: render them into a quarter-area (half-width x
10
- // half-height) offscreen target instead of the full canvas, then composite back with a
11
- // depth-aware upsample so the half-res edge doesn't bleed through nearer opaque geometry.
12
- //
13
- // HOW OBJECTS OPT IN: THREE.Layers, not a material/type check. Any object (or its material) that
14
- // should render half-res sets `object.layers.set(HALFRES_LAYER)` (or `.layers.enable(HALFRES_LAYER)`
15
- // if it also needs to stay on layer 0 for other purposes, e.g. raycasting) -- see `tagHalfRes(obj)`
16
- // helper. The main scene camera then EXCLUDES that layer (camera.layers.disable(HALFRES_LAYER)) so
17
- // scene-color's normal full-res render skips it entirely, and this module's own half-res camera
18
- // (cloned near/far/fov from the main camera every frame, so projections match exactly) INCLUDES
19
- // ONLY that layer. Layer-based opt-in (vs. a scene-graph split or a material flag) was chosen
20
- // because it needs zero change to how/where an object is parented (Weather.js's InstancedMesh2
21
- // pools stay `scene.add`-ed exactly where they are; the RenderGraph node here calls
22
- // `installHalfResTransparents(...).tagHalfRes(im)` once at construction) and composes cleanly with
23
- // THREE's own existing frustum-cull/raycast layer semantics.
24
- //
25
- // DEPTH-AWARE COMPOSITE: unlike Bloom (whole-canvas copy, no occlusion concern) this pass MUST NOT
26
- // let a half-res particle draw over nearer opaque geometry it should be occluded by (e.g. rain
27
- // behind a wall). DepthComposite.js's contract is that the ONLY depth buffer is the shared canvas
28
- // depth (mapspinner + THREE, no offscreen scene depth texture exists) -- so, matching SSAO.js's own
29
- // solved-this-already approach, this module renders its OWN small half-res depth-only pre-pass of
30
- // the OPAQUE scene (camera.layers default, i.e. layer 0 only -- excludes HALFRES_LAYER) into the
31
- // same-size target's depth attachment, then renders the half-res-layer objects against that depth
32
- // (real GPU depth-test, not a shader trick) before compositing color back.
33
- //
34
- // BILATERAL (DEPTH-REJECT) UPSAMPLE (bilateral-upsample-fps-delta follow-up): the composite step
35
- // that brings the half-res color target back onto the full-res canvas is a real bilateral filter,
36
- // not plain bilinear -- the depth-only pre-pass ALSO encodes its linear view-space depth into a
37
- // second small texture (_depthTarget, blitted from the pre-pass's color output each compute(), see
38
- // step 1.5), and the composite shader (_compositeFrag) weights each of the 4 bilinear-neighborhood
39
- // taps by how close ITS recorded opaque-scene depth is to the tap nearest the pixel being upsampled
40
- // -- a tap on the far side of a depth discontinuity (e.g. a thin foreground rail) is down-weighted
41
- // so its transparent color doesn't bleed across the edge. This uses the pass's OWN half-res depth as
42
- // the edge/discontinuity proxy (documented option (b) from the original follow-up row) rather than a
43
- // full-resolution THREE-only depth reference, since no such texture exists in this pipeline (see
44
- // DepthComposite.js's shared-canvas-depth contract above) and standing one up purely for this would
45
- // cost a second full-res depth-only scene render -- against the entire point of this pass being a
46
- // perf win. uDepthSigma (RenderControls('halfResTransparentsDepthSigma')) tunes the rejection
47
- // sensitivity.
48
- //
49
- // OVERRIDE MATERIAL, NOT EACH OBJECT'S REAL MATERIAL (live-debugged this session): the depth
50
- // pre-pass uses `scene.overrideMaterial` with a single shared minimal depth-only ShaderMaterial --
51
- // the SAME technique SSAO.js's G-buffer pass already uses (`_gbufferMat`) -- rather than letting
52
- // each opaque object render through its own normal material with color writes masked off. Tried the
53
- // real-material approach first; it produced a real, live-witnessed
54
- // "GL_INVALID_OPERATION: glDrawArraysInstanced: Vertex shader input type does not match the type of
55
- // the bound vertex attribute" storm, root-caused to InstancedMesh2's onBeforeRender/bindTextures
56
- // hook (see node_modules/@three.ez/instanced-mesh's InstancedMesh2.js): it snapshots and restores
57
- // `gl.CURRENT_PROGRAM` around a manual texture-uniform bind per draw call, and rendering the SAME
58
- // InstancedMesh2 objects (vegetation/rocks/grass) through their normal per-object materials in TWO
59
- // separate renderer.render() calls within one frame (once here, once in scene-color) left stale
60
- // attribute-pointer state from the wrong program bound on the second pass. A single shared
61
- // override material sidesteps this entirely -- InstancedMesh2's onBeforeRender still runs (needed
62
- // for its own instance-index/matrix-texture update), but every opaque object now renders through
63
- // the IDENTICAL program, so there is no per-object program churn within this pre-pass to desync.
64
-
65
- import * as THREE from 'three'
66
- import { RenderControls } from './RenderControls.js'
67
-
68
- // Layer index reserved for half-res-transparent content. Layers 0 (default) and the small set THREE
69
- // itself never reserves are otherwise free in this codebase (codesearch: no other `.layers.` use
70
- // exists yet) -- 1 chosen as the first non-default layer, documented here as the ONE place this
71
- // number is decided.
72
- export const HALFRES_LAYER = 1
73
-
74
- const _fullscreenVert = /* glsl */`
75
- varying vec2 vUv;
76
- void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
77
- `
78
-
79
- // Plain nearest-sample copy, used ONCE per compute() to blit the depth pre-pass's color output
80
- // (linear-depth-in-alpha) from _target into the separate _depthTarget before _target's color gets
81
- // overwritten by the transparent-layer draw -- see compute() step 1.5. depthTest/depthWrite off,
82
- // opaque blending (source overwrites destination fully, no accumulation).
83
- const _copyFrag = /* glsl */`
84
- precision highp float;
85
- varying vec2 vUv;
86
- uniform sampler2D tSrc;
87
- void main() { gl_FragColor = texture2D(tSrc, vUv); }
88
- `
89
-
90
- // Depth-reject BILATERAL upsample (bilateral-upsample-fps-delta follow-up to
91
- // half-res-transparents-particles-pass). This codebase has no full-resolution THREE-only depth
92
- // texture to reject against (DepthComposite.js's contract: the only depth buffer is the shared
93
- // mapspinner+THREE canvas depth) -- so per that row's documented option (b), this uses the pass's
94
- // OWN half-res opaque-scene depth (already rendered every compute() as a real GPU depth-test pre-
95
- // pass, see _depthOnlyMat below) as a cheap edge/discontinuity proxy: the depth pre-pass now ALSO
96
- // encodes linear view-space depth into a second half-res texture (tHalfDepth, same technique SSAO.js
97
- // already uses -- pack raw metres into a color channel, no depth-curve reconstruction needed since
98
- // this module owns the encode AND decode end to end), and the composite shader does a real bilateral
99
- // filter over the 2x2 texel neighborhood around each upsampled sample: taps whose depth diverges from
100
- // the others are down-weighted (Gaussian falloff on depth delta, uDepthSigma controls sensitivity),
101
- // so a half-res transparent silhouette crossing a depth discontinuity (e.g. rain partially behind a
102
- // thin foreground rail) samples mostly from the texel(s) on the SAME side of the discontinuity as the
103
- // pixel being upsampled, instead of plain-bilinear's blend-across-the-edge blur/bleed.
104
- const _compositeFrag = /* glsl */`
105
- precision highp float;
106
- varying vec2 vUv;
107
- uniform sampler2D tColor;
108
- uniform sampler2D tHalfDepth;
109
- uniform vec2 uHalfTexelSize; // 1/halfResTargetSize, in the SAME uv space as tColor/tHalfDepth
110
- uniform float uDepthSigma; // metres -- depth-delta scale at which a bilateral tap's weight halves
111
-
112
- float sampleDepth(vec2 uv) { return texture2D(tHalfDepth, uv).a; }
113
-
114
- void main() {
115
- // Center depth at the exact half-res texel nearest this full-res pixel (nearest, not bilinear --
116
- // this is the reference depth the 4 corner taps below get weighted against).
117
- float centerDepth = sampleDepth(vUv);
118
-
119
- // 2x2 bilinear-neighborhood taps, offset a half-texel in each diagonal direction so the four
120
- // samples straddle the standard bilinear interpolation square around vUv.
121
- vec2 o = uHalfTexelSize * 0.5;
122
- vec2 uv00 = vUv + vec2(-o.x, -o.y);
123
- vec2 uv10 = vUv + vec2( o.x, -o.y);
124
- vec2 uv01 = vUv + vec2(-o.x, o.y);
125
- vec2 uv11 = vUv + vec2( o.x, o.y);
126
-
127
- vec4 c00 = texture2D(tColor, uv00);
128
- vec4 c10 = texture2D(tColor, uv10);
129
- vec4 c01 = texture2D(tColor, uv01);
130
- vec4 c11 = texture2D(tColor, uv11);
131
-
132
- float d00 = sampleDepth(uv00);
133
- float d10 = sampleDepth(uv10);
134
- float d01 = sampleDepth(uv01);
135
- float d11 = sampleDepth(uv11);
136
-
137
- // Depth-reject weights: a tap whose opaque-scene depth is far from centerDepth sits on the OTHER
138
- // side of a discontinuity from the pixel being upsampled -- down-weight it so its transparent
139
- // color does not bleed across the edge. Gaussian falloff, uDepthSigma tunes sensitivity; a tap
140
- // with zero recorded depth (nothing opaque drawn there, e.g. sky) is never rejected (weight 1) so
141
- // sky-only half-res regions still get plain bilinear quality.
142
- float w00 = d00 <= 0.0 ? 1.0 : exp(-pow((d00 - centerDepth) / uDepthSigma, 2.0));
143
- float w10 = d10 <= 0.0 ? 1.0 : exp(-pow((d10 - centerDepth) / uDepthSigma, 2.0));
144
- float w01 = d01 <= 0.0 ? 1.0 : exp(-pow((d01 - centerDepth) / uDepthSigma, 2.0));
145
- float w11 = d11 <= 0.0 ? 1.0 : exp(-pow((d11 - centerDepth) / uDepthSigma, 2.0));
146
-
147
- float wSum = w00 + w10 + w01 + w11;
148
- vec4 c;
149
- if (wSum < 1e-5) {
150
- // All 4 taps rejected (shouldn't normally happen since same-side-as-center always weight~1) --
151
- // fall back to the plain nearest-center sample rather than dividing by zero.
152
- c = texture2D(tColor, vUv);
153
- } else {
154
- c = (c00 * w00 + c10 * w10 + c01 * w01 + c11 * w11) / wSum;
155
- }
156
-
157
- if (c.a < 0.003) discard;
158
- gl_FragColor = c;
159
- }
160
- `
161
-
162
- // Depth-only override material for the opaque-scene pre-pass (see header's OVERRIDE MATERIAL note).
163
- // Fragment output now encodes LINEAR VIEW-SPACE DEPTH (raw metres, this module's own camera -- same
164
- // packing convention as SSAO.js's G-buffer, see that file for why: cheap and exact since encode+decode
165
- // are both owned here, no depth-curve reconstruction needed) into the alpha channel, read back by the
166
- // bilateral composite above as the edge/discontinuity proxy. rgb is unused (kept 0) -- only .a matters.
167
- const _depthOnlyVert = /* glsl */`
168
- varying float vViewDepth;
169
- void main() {
170
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
171
- vViewDepth = -mvPosition.z;
172
- gl_Position = projectionMatrix * mvPosition;
173
- }
174
- `
175
- const _depthOnlyFrag = /* glsl */`
176
- varying float vViewDepth;
177
- void main() { gl_FragColor = vec4(0.0, 0.0, 0.0, vViewDepth); }
178
- `
179
- function _makeDepthOnlyMaterial() {
180
- return new THREE.ShaderMaterial({ vertexShader: _depthOnlyVert, fragmentShader: _depthOnlyFrag, side: THREE.DoubleSide })
181
- }
182
-
183
- export class HalfResTransparents {
184
- constructor(renderer, scene, camera) {
185
- this.renderer = renderer
186
- this.scene = scene
187
- this.camera = camera
188
- this._w = 0
189
- this._h = 0
190
- this._built = false
191
- this._hasContent = false
192
-
193
- // Own camera clone kept in sync (near/far/fov/aspect/position/quaternion) every frame from the
194
- // main camera -- a SEPARATE object (not the same camera instance) so this pass's layer mask
195
- // never has to be swapped on/off the live main camera mid-frame (which would race the
196
- // scene-color node if node ordering ever changed).
197
- this._halfCam = camera.clone()
198
- this._halfCam.layers.set(HALFRES_LAYER)
199
-
200
- this._depthCam = camera.clone()
201
- this._depthCam.layers.disableAll()
202
- this._depthCam.layers.enable(0) // opaque scene only -- everything NOT tagged half-res
203
-
204
- this._depthOnlyMat = _makeDepthOnlyMaterial()
205
-
206
- this._quadScene = new THREE.Scene()
207
- this._quadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)
208
- this._quadGeo = new THREE.PlaneGeometry(2, 2)
209
- this._compositeMat = new THREE.ShaderMaterial({
210
- vertexShader: _fullscreenVert,
211
- fragmentShader: _compositeFrag,
212
- uniforms: {
213
- tColor: { value: null },
214
- tHalfDepth: { value: null },
215
- uHalfTexelSize: { value: new THREE.Vector2(1, 1) },
216
- // Depth-delta scale (metres) at which a bilateral tap's weight halves. 2m default: tight
217
- // enough to reject across a thin-object discontinuity (e.g. a ~0.1-0.3m rail/post) without
218
- // over-rejecting on smoothly-varying terrain/foliage depth gradients within one half-res texel
219
- // footprint. Exposed as a RenderControls knob (halfResTransparentsDepthSigma) rather than
220
- // hardcoded so it can be tuned per-scene without a source edit.
221
- uDepthSigma: { value: 2.0 },
222
- },
223
- transparent: true,
224
- depthTest: false,
225
- depthWrite: false,
226
- blending: THREE.NormalBlending,
227
- })
228
- this._quad = new THREE.Mesh(this._quadGeo, this._compositeMat)
229
- this._quad.frustumCulled = false
230
- this._quadScene.add(this._quad)
231
-
232
- this._copyMat = new THREE.ShaderMaterial({
233
- vertexShader: _fullscreenVert,
234
- fragmentShader: _copyFrag,
235
- uniforms: { tSrc: { value: null } },
236
- depthTest: false,
237
- depthWrite: false,
238
- })
239
- this._copyQuad = new THREE.Mesh(this._quadGeo, this._copyMat)
240
- this._copyQuad.frustumCulled = false
241
- this._copyScene = new THREE.Scene()
242
- this._copyScene.add(this._copyQuad)
243
- }
244
-
245
- // Marks an object (and, when it has one, its immediate children -- InstancedMesh2 pools are a
246
- // single object so this is usually a one-call tag) to render ONLY in this half-res pass. `also0`
247
- // (default false) additionally keeps layer 0 enabled -- use when the object still needs to
248
- // participate in a layer-0-scoped system (raycast pick, occlusion query) that this codebase does
249
- // not yet route through a custom layer; Weather.js's particle pools have no such requirement
250
- // (perObjectFrustumCulled=false, no raycast target), so the default (layers.set = layer-0 OFF)
251
- // is correct for them.
252
- tagHalfRes(object, also0 = false) {
253
- if (also0) object.layers.enable(HALFRES_LAYER)
254
- else object.layers.set(HALFRES_LAYER)
255
- }
256
-
257
- untagHalfRes(object) {
258
- object.layers.set(0)
259
- }
260
-
261
- _ensureTargets(fullW, fullH) {
262
- const scale = RenderControls.get('halfResTransparentsScale') || 0.5
263
- const w = Math.max(4, Math.floor(fullW * scale))
264
- const h = Math.max(4, Math.floor(fullH * scale))
265
- if (this._built && w === this._w && h === this._h) return
266
- this._disposeTargets()
267
- this._w = w; this._h = h
268
- // depthBuffer:true -- this target owns a REAL depth attachment so the transparent-layer render
269
- // gets genuine GPU depth-testing against the opaque-depth pre-pass below, not a shader-side
270
- // approximation. Depth is never read back (no DepthTexture attached) -- only used internally by
271
- // the depth-test/depth-write state during the two render calls into this same target.
272
- this._target = new THREE.WebGLRenderTarget(w, h, {
273
- type: THREE.HalfFloatType,
274
- format: THREE.RGBAFormat,
275
- minFilter: THREE.LinearFilter,
276
- magFilter: THREE.LinearFilter,
277
- depthBuffer: true,
278
- wrapS: THREE.ClampToEdgeWrapping,
279
- wrapT: THREE.ClampToEdgeWrapping,
280
- })
281
- // Separate small target the depth-only pre-pass's color output (linear-depth-in-alpha, see
282
- // _depthOnlyFrag) is captured INTO, distinct from _target -- _target's own color attachment gets
283
- // overwritten by the transparent-layer draw in step 2 of compute(), so the depth-reject composite
284
- // needs its own place to read the pre-pass's depth from. NearestFilter (not Linear): this texture
285
- // is sampled at exact texel centers by the composite's own 2x2-neighborhood taps, a bilinear
286
- // sample here would blur two different opaque-scene depths together before the bilateral filter
287
- // ever sees them, defeating the point of a depth-aware upsample.
288
- this._depthTarget = new THREE.WebGLRenderTarget(w, h, {
289
- type: THREE.HalfFloatType,
290
- format: THREE.RGBAFormat,
291
- minFilter: THREE.NearestFilter,
292
- magFilter: THREE.NearestFilter,
293
- depthBuffer: false,
294
- wrapS: THREE.ClampToEdgeWrapping,
295
- wrapT: THREE.ClampToEdgeWrapping,
296
- })
297
- this._compositeMat.uniforms.uHalfTexelSize.value.set(1 / w, 1 / h)
298
- this._built = true
299
- }
300
-
301
- _disposeTargets() {
302
- if (this._target) this._target.dispose()
303
- if (this._depthTarget) this._depthTarget.dispose()
304
- }
305
-
306
- _syncCam(src, dst) {
307
- dst.near = src.near; dst.far = src.far
308
- if (src.fov !== undefined) dst.fov = src.fov
309
- if (src.aspect !== undefined) dst.aspect = src.aspect
310
- dst.position.copy(src.position)
311
- dst.quaternion.copy(src.quaternion)
312
- dst.updateProjectionMatrix()
313
- dst.updateMatrixWorld(true)
314
- }
315
-
316
- // Renders the half-res-layer content (opaque-depth pre-pass + transparent-layer draw) into the
317
- // offscreen target. Does not composite -- callers read this._target.texture and composite
318
- // explicitly (mirrors SSAO/Bloom's compute()/composite() split, one RenderGraph node per resource).
319
- compute() {
320
- const size = new THREE.Vector2()
321
- this.renderer.getSize(size)
322
- if (size.x <= 0 || size.y <= 0) { this._hasContent = false; return }
323
- this._ensureTargets(size.x, size.y)
324
- this._syncCam(this.camera, this._halfCam)
325
- this._syncCam(this.camera, this._depthCam)
326
-
327
- const prevTarget = this.renderer.getRenderTarget()
328
- const prevAutoClear = this.renderer.autoClear
329
- const prevClearColor = new THREE.Color()
330
- this.renderer.getClearColor(prevClearColor)
331
- const prevClearAlpha = this.renderer.getClearAlpha()
332
-
333
- this.renderer.setRenderTarget(this._target)
334
- this.renderer.autoClear = true
335
- this.renderer.setClearColor(0x000000, 0)
336
- this.renderer.clear(true, true, false)
337
-
338
- // 1. Opaque-depth pre-pass: draw the NON-half-res scene through the shared depth-only override
339
- // material (see header's OVERRIDE MATERIAL note -- NOT each object's own material, and NOT a
340
- // colorMask trick), so the depth buffer is populated -- gives the transparent-layer draw below
341
- // real occlusion against nearer walls/terrain-proxy geometry without a second full-color render
342
- // and without the per-object program churn that broke InstancedMesh2's texture-bind cache when
343
- // this pre-pass used to render through real materials. The color output now ALSO encodes linear
344
- // view-space depth (see _depthOnlyFrag) -- read below by the blit step for the bilateral
345
- // composite's depth-reject reference.
346
- const prevOverride = this.scene.overrideMaterial
347
- this.renderer.autoClear = false
348
- this.scene.overrideMaterial = this._depthOnlyMat
349
- this.renderer.render(this.scene, this._depthCam)
350
- this.scene.overrideMaterial = prevOverride
351
-
352
- // 1.5. Blit _target's just-rendered depth-encoded color into the separate _depthTarget, BEFORE
353
- // step 2 overwrites _target's color with the transparent draw -- one small nearest-filtered
354
- // fullscreen-quad copy at half-res, cheap relative to the scene draw in step 1 it depends on.
355
- this.renderer.setRenderTarget(this._depthTarget)
356
- this._copyMat.uniforms.tSrc.value = this._target.texture
357
- this.renderer.render(this._copyScene, this._quadCamera)
358
- this.renderer.setRenderTarget(this._target)
359
-
360
- // 2. Transparent/particle layer: real depth-test against the pre-pass above, normal blending,
361
- // depthWrite left as each material already configures it (Weather.js's particle materials
362
- // already set depthWrite:false, matching how they draw in the full-res path today).
363
- this.renderer.render(this.scene, this._halfCam)
364
-
365
- this.renderer.setRenderTarget(prevTarget)
366
- this.renderer.autoClear = prevAutoClear
367
- this.renderer.setClearColor(prevClearColor, prevClearAlpha)
368
- this._hasContent = true
369
- }
370
-
371
- // Composites the half-res target back onto whatever is currently bound as the render target (the
372
- // canvas, called from the RenderGraph composite node after scene-color).
373
- composite() {
374
- if (!this._hasContent || !this._target) return
375
- this._compositeMat.uniforms.tColor.value = this._target.texture
376
- this._compositeMat.uniforms.tHalfDepth.value = this._depthTarget.texture
377
- this._compositeMat.uniforms.uDepthSigma.value = RenderControls.get('halfResTransparentsDepthSigma') ?? 2.0
378
- const prevAutoClear = this.renderer.autoClear
379
- this.renderer.autoClear = false
380
- this.renderer.render(this._quadScene, this._quadCamera)
381
- this.renderer.autoClear = prevAutoClear
382
- }
383
-
384
- dispose() {
385
- this._disposeTargets()
386
- this._compositeMat.dispose()
387
- this._depthOnlyMat.dispose()
388
- this._copyMat.dispose()
389
- this._quadGeo.dispose()
390
- }
391
- }
392
-
393
- // RenderGraph nodes -- declared-resource, gated behind RenderControls('halfResTransparents'),
394
- // composited AFTER scene-color (same position as SSAO/Bloom). ctx.halfResTransparents is a lazily-
395
- // built instance (see installHalfResTransparents) so a session that never enables the flag pays
396
- // zero construction cost.
397
- //
398
- // CAMERA LAYER GATING: scene-color (RenderGraph.nodes.js) renders with `ctx.camera` UNCHANGED --
399
- // when this pass is OFF, ctx.camera's layer mask must include layer 0 only (THREE's default,
400
- // nothing tagged HALFRES_LAYER is ever added unless installHalfResTransparents' caller tags it), so
401
- // half-res-tagged objects simply do not exist as far as the main camera/scene-color is concerned
402
- // UNLESS the pass is enabled, in which case `hrt-gate-main-camera` (below) disables HALFRES_LAYER on
403
- // the real ctx.camera so scene-color skips it (this pass draws it instead) -- and re-enables it the
404
- // moment the flag is turned off so a live toggle never permanently hides tagged content.
405
- export function buildHalfResTransparentsNodes() {
406
- return [
407
- {
408
- id: 'hrt-gate-main-camera',
409
- reads: [],
410
- writes: ['hrtGated'],
411
- // hrtGated is a pure on/off stamp -- no other node reads it; the real effect is entirely
412
- // external (ctx.camera.layers enable/disable state consumed by THREE's own render() call in
413
- // scene-color, not by another RenderGraph node's declared `reads`). See RenderGraph.js's NODE
414
- // CONTRACT `terminal` doc.
415
- terminal: true,
416
- shouldRun: ctx => !!ctx.halfResTransparents,
417
- run(ctx) {
418
- const on = RenderControls.get('halfResTransparents') === true
419
- if (on) ctx.camera.layers.disable(HALFRES_LAYER)
420
- else ctx.camera.layers.enable(HALFRES_LAYER)
421
- ctx.res.hrtGated = on
422
- },
423
- },
424
- {
425
- id: 'hrt-compute',
426
- reads: ['sceneColor'],
427
- writes: ['hrtComputed'],
428
- shouldRun: ctx => RenderControls.get('halfResTransparents') === true && !!ctx.halfResTransparents,
429
- run(ctx) {
430
- ctx.halfResTransparents.compute()
431
- ctx.res.hrtComputed = ctx.frameId
432
- },
433
- },
434
- {
435
- id: 'hrt-composite',
436
- reads: ['hrtComputed'],
437
- writes: ['hrtComposited'],
438
- targets: { hrtComposited: 'canvas' },
439
- shouldRun: ctx => RenderControls.get('halfResTransparents') === true && !!ctx.halfResTransparents && ctx.res.hrtComputed === ctx.frameId,
440
- run(ctx) {
441
- ctx.halfResTransparents.composite()
442
- ctx.res.hrtComposited = ctx.frameId
443
- },
444
- },
445
- ]
446
- }
447
-
448
- // Lazy installer: constructs GPU resources on first call and stashes the instance on
449
- // ctx.halfResTransparents. NAMING TRAP AVOIDED (same discipline as SSAO/Bloom): the debug instance
450
- // handle is window.__halfResTransparentsDebug, NEVER window.__halfResTransparents -- RenderControls
451
- // mirrors the boolean flag onto that exact global name.
452
- export function installHalfResTransparents(ctx, renderer, scene, camera) {
453
- if (!ctx.halfResTransparents) ctx.halfResTransparents = new HalfResTransparents(renderer, scene, camera)
454
- if (typeof window !== 'undefined') window.__halfResTransparentsDebug = ctx.halfResTransparents
455
- return ctx.halfResTransparents
456
- }