spoint 0.1.656 → 0.1.658

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.
@@ -12,6 +12,11 @@ import { createCachedAnchorField } from '/src/terrain/ClimateCache.js'
12
12
  import { createBiomeOverride } from '/src/terrain/BiomeOverride.js'
13
13
  import { createGrassDecal } from '/src/terrain/GrassDecal.js'
14
14
  import { dbg } from './debug-log.js'
15
+ import { MAX_BENDERS, MAX_DECALS, makeBladeGeo, makeWind, makeGrassMaterial } from './GrassMaterial.js'
16
+
17
+ // Re-exported from GrassMaterial.js for backward compatibility (RenderGraph.nodes.js references
18
+ // Grass.MAX_BENDERS in its own comments; no current importer reaches these, kept for API stability).
19
+ export { MAX_BENDERS, MAX_DECALS }
15
20
 
16
21
  const _dbgGrass = dbg('grass')
17
22
  const _occBoxGeo = new THREE.BoxGeometry(1, 1, 1) // shared, never-rendered proxy geo for occlusion candidates
@@ -20,257 +25,6 @@ const _occBoxMat = new THREE.MeshBasicMaterial()
20
25
  const DROP_MARGIN = 16
21
26
  const _v = new THREE.Vector3(), _q = new THREE.Quaternion(), _camPos = new THREE.Vector3()
22
27
 
23
- // 2 crossed tapered triangles (one along x, one along z), base at y=0, tip at y=1 (scaled per instance).
24
- // Multi-segment curved ribbon blade (bends along its length under wind), two crossed quads for silhouette volume, one shared geometry across all instances.
25
- // N is parameterized for the 2-geometry-tier LOD built in createGrass below: N=5 near (curved, 20
26
- // tris/blade), N=1 mid (flat crossed quad, 4 tris/blade -- the curve term still bakes in via the
27
- // tip-only bend so a 1-segment blade still leans, it just can't bow mid-blade). Beyond the mid tier's
28
- // cutoff blade curvature is genuinely invisible (sub-pixel at >15m per the PRD row); the THIRD (far) tier
29
- // named by the task is deliberately the existing chunk-unload boundary + vertex-shader ring-fade, not a
30
- // third real geometry -- see createGrass's LOD block comment for the full scope-choice rationale.
31
- function makeBladeGeo(segments) {
32
- const N = Number.isFinite(segments) && segments >= 1 ? segments | 0 : 5
33
- const wBase = 0.07, curve = 0.18 // base half-width, baked forward arc (m at tip)
34
- const pos = [], idx = []
35
- const quads = [[[-1, 0], [1, 0]], [[0, -1], [0, 1]]]
36
- let vi = 0
37
- for (const [a, b] of quads) {
38
- for (let s = 0; s <= N; s++) {
39
- const v = s / N
40
- const w = wBase * (1 - v)
41
- const bend = curve * v * v
42
- pos.push(a[0] * w + bend, v, a[1] * w, b[0] * w + bend, v, b[1] * w)
43
- }
44
- for (let s = 0; s < N; s++) {
45
- const r0 = vi + s * 2, r1 = r0 + 2
46
- idx.push(r0, r0 + 1, r1, r0 + 1, r1 + 1, r1)
47
- }
48
- vi += (N + 1) * 2
49
- }
50
- const g = new THREE.BufferGeometry()
51
- g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pos), 3))
52
- g.setIndex(idx)
53
- g.computeVertexNormals()
54
- g.computeBoundingSphere(); g.computeBoundingBox()
55
- return g
56
- }
57
-
58
- // Player/actor bend: a small fixed-size array of nearby world-space XZ positions the vertex shader
59
- // pushes blades radially away from (base-anchored, same tip-weighted falloff as the wind bend below),
60
- // springing back upright once a bender leaves each blade's influence radius. MAX_BENDERS caps the
61
- // uniform array size (and the per-frame CPU cost of building it) -- grass render distance is tens of
62
- // metres, so a handful of nearby players/actors is the realistic max ever influencing one visible
63
- // blade simultaneously; RenderGraph.nodes.js's foliage-lod-sync feeds only the nearest MAX_BENDERS
64
- // actors within grassBendRadius of the local player, sorted by distance, so the cap silently degrades
65
- // (farthest excess actors just don't bend grass) rather than ever overflowing the array.
66
- export const MAX_BENDERS = 8
67
-
68
- // Burn/flatten decals: a small fixed-size array of nearby scorch-stamp centers (world-space XZ, same
69
- // space as uBenderPosXZ above), each with its own radius+strength, that the vertex shader shrinks and
70
- // re-tints blades within. Distinct from the bender system above: benders are TRANSIENT (rebuilt fresh
71
- // every frame from live actor positions, zero persisted state, springs back the instant an actor
72
- // leaves), decals are PERSISTENT (backed by src/terrain/GrassDecal.js's sparse cell-Map world-state
73
- // store -- once markScorched is called the effect stays until an explicit clear/regrowth, independent
74
- // of any actor being nearby). MAX_DECALS caps the uniform array + per-frame nearest-stamp scan cost,
75
- // same rationale as MAX_BENDERS: grass render distance is tens of metres so only a handful of decals
76
- // are ever in view at once; nearestStamps() silently degrades (farthest excess decals just don't
77
- // apply) rather than overflowing.
78
- export const MAX_DECALS = 8
79
-
80
- function makeWind() {
81
- return {
82
- uGrassTime: { value: 0 }, uGrassWind: { value: 1 }, uGrassWindDir: { value: new THREE.Vector2(0.8, 0.6) },
83
- uCamPosXZ: { value: new THREE.Vector2(0, 0) }, uGrassRing: { value: 44 },
84
- uSunDir: { value: new THREE.Vector3(0.4, 0.8, 0.3).normalize() }, uSunColor: { value: new THREE.Color(1, 1, 0.96) },
85
- uAmbient: { value: new THREE.Color(0.32, 0.36, 0.4) },
86
- // uBenderPosXZ: MAX_BENDERS packed (x,z) pairs (world XZ, same space as instanceMatrix). Unused
87
- // slots hold a position far outside any real chunk so their falloff term is always ~0 (cheaper than
88
- // branching per-slot in the shader). uBenderCount lets the loop skip empty slots outright.
89
- uBenderPosXZ: { value: new Float32Array(MAX_BENDERS * 2).fill(1e6) },
90
- uBenderCount: { value: 0 },
91
- uGrassBendRadius: { value: 2.2 },
92
- uGrassBendStrength: { value: 1.4 },
93
- // uDecalPosXZRS: MAX_DECALS packed (x,z,radius,strength) quads. Unused slots hold radius=0 so their
94
- // influence term is always exactly 0 regardless of position (cheaper/safer than a sentinel-distance
95
- // trick since radius, not distance, gates the falloff here). uDecalCount lets the loop skip empty
96
- // slots outright, same pattern as uBenderCount.
97
- uDecalPosXZRS: { value: new Float32Array(MAX_DECALS * 4) },
98
- uDecalCount: { value: 0 },
99
- uGrassScorchShrink: { value: 0.15 }, // blade scale multiplier at full scorch influence (near-flattened, not fully zero -- a scorched patch still has stubble)
100
- uGrassScorchColor: { value: new THREE.Color(0.22, 0.15, 0.06) }, // dry/burnt tint blended in at full influence
101
- }
102
- }
103
-
104
- // Hand-written Lambert-ish ShaderMaterial: replaces MeshStandardMaterial's full PBR (GGX specular,
105
- // env IBL, real shadow-map PCF) with the cheap lighting model grass actually needs -- the fragment
106
- // shader already discards most of the PBR output by overriding color and flattening the normal 60%
107
- // toward up, so paying for GGX/IBL/PCF per fragment across tens of thousands of overlapping blades was
108
- // pure waste. FrontSide only (was DoubleSide): back faces are flipped via gl_FrontFacing in the
109
- // fragment stage instead of being drawn a second time, halving rasterized fragments for away-facing
110
- // blades while keeping the same two-crossed-quad silhouette. No alphaTest (blades are geometric, not
111
- // alpha-cutout) and no real shadow-map sampling (see uInstShadow -- a per-instance cached terrain-shadow
112
- // scalar set once per blade instance, sampled in the vertex shader, never a per-fragment PCF fetch).
113
- function makeGrassMaterial(wind) {
114
- const material = new THREE.ShaderMaterial({
115
- uniforms: {
116
- uGrassTime: wind.uGrassTime,
117
- uGrassWind: wind.uGrassWind,
118
- uGrassWindDir: wind.uGrassWindDir,
119
- uCamPosXZ: wind.uCamPosXZ,
120
- uGrassRing: wind.uGrassRing,
121
- uSunDir: wind.uSunDir,
122
- uSunColor: wind.uSunColor,
123
- uAmbient: wind.uAmbient,
124
- uBenderPosXZ: wind.uBenderPosXZ,
125
- uBenderCount: wind.uBenderCount,
126
- uGrassBendRadius: wind.uGrassBendRadius,
127
- uGrassBendStrength: wind.uGrassBendStrength,
128
- uDecalPosXZRS: wind.uDecalPosXZRS,
129
- uDecalCount: wind.uDecalCount,
130
- uGrassScorchShrink: wind.uGrassScorchShrink,
131
- uGrassScorchColor: wind.uGrassScorchColor
132
- },
133
- side: THREE.FrontSide,
134
- // no alphaTest: blades are opaque triangle geometry, alphaTest would defeat early-Z for nothing gained
135
- vertexShader: `
136
- uniform float uGrassTime, uGrassWind, uGrassRing;
137
- uniform vec2 uGrassWindDir, uCamPosXZ;
138
- uniform vec2 uBenderPosXZ[${MAX_BENDERS}];
139
- uniform int uBenderCount;
140
- uniform float uGrassBendRadius, uGrassBendStrength;
141
- // uDecalPosXZRS: packed (x,z,radius,strength) per decal -- burn/flatten world-state, see
142
- // src/terrain/GrassDecal.js. Unlike the bender loop above (radius is a single shared uniform),
143
- // each decal carries its OWN radius+strength since real-world stamps (a small vehicle track vs a
144
- // large explosion crater) vary in both.
145
- uniform vec4 uDecalPosXZRS[${MAX_DECALS}];
146
- uniform int uDecalCount;
147
- uniform float uGrassScorchShrink;
148
- uniform vec3 uGrassScorchColor;
149
- // windPhase/tint/instShadow are NOT declared here -- InstancedMesh2.initUniformsPerInstance's
150
- // material patch (wrapping this material's onBeforeCompile/customProgramCacheKey, see Uniforms.js
151
- // + SquareDataTexture.getUniformsVertexGLSL) injects their float name; global declarations and
152
- // per-instance texel-fetch assignment itself, ahead of this shader's own void main() body.
153
- // instShadow: per-instance cached terrain-shadow scalar (0=fully shadowed .. 1=fully lit), set once
154
- // per blade instance at placement time from the terrain-slope self-shadow approximation in
155
- // src/terrain/GrassPlacement.js -- never a real per-fragment shadow-map PCF fetch.
156
- //
157
- // instancedmesh2-instanceindex-undeclared-identifier-vegetation-shader: InstancedMesh2's per-instance
158
- // uniform injection (the windPhase/instShadow/tint texel-fetch above) ALSO needs the instanceIndex
159
- // vertex attribute in scope -- normally provided for free by THREE's own ShaderLib templates via
160
- // '#include <batching_pars_vertex>' (which @three.ez's ShaderChunk.js concatenates its own
161
- // instanced_pars_vertex chunk onto), but this is a hand-written raw ShaderMaterial with NEITHER
162
- // include, so instanceIndex was genuinely undeclared -- real live GL compile failure caught via a
163
- // WebGL2RenderingContext.prototype.compileShader monkeypatch (ERROR 0:86/0:177 'instanceIndex' :
164
- // undeclared identifier), reproduced live at PORT=8250 after ~29s of real gameplay streaming grass
165
- // chunks in. '#include <instanced_pars_vertex>' (resolved by THREE's own resolveIncludes, which runs
166
- // on every material's final shader string, ShaderMaterial included) declares BOTH instanceIndex and
167
- // getInstancedMatrix(). This InstancedMesh2 also always sets USE_INSTANCING_INDIRECT (see
168
- // InstancedMesh2.js _onBeforeCompile), which makes the raw instanceMatrix ATTRIBUTE a dummy
169
- // zero-length buffer (the real per-instance matrix lives in matricesTexture instead) -- so every
170
- // pre-existing raw instanceMatrix read below was ALSO silently wrong (would have rendered
171
- // degenerate/zeroed blade transforms once the instanceIndex fix alone made this shader compile);
172
- // fixed by locally shadowing instanceMatrix with the real computed matrix, the same pattern THREE's
173
- // own instanced_vertex chunk uses for its built-in ShaderLib materials.
174
- varying float vGrassY, vTint, vInstShadow, vScorch;
175
- varying vec3 vWorldNormal;
176
- #include <common>
177
- #include <instanced_pars_vertex>
178
- void main() {
179
- #ifdef USE_INSTANCING_INDIRECT
180
- mat4 instanceMatrix = getInstancedMatrix();
181
- #endif
182
- vGrassY = position.y;
183
- vTint = tint;
184
- vInstShadow = instShadow;
185
- vec3 transformed = position;
186
- vec2 gWXZ = instanceMatrix[3].xz;
187
- float gv = clamp(position.y, 0.0, 1.0);
188
- float gw = gv * gv * 0.45;
189
- float gFlow = sin(dot(gWXZ, vec2(0.06, 0.045)) + uGrassTime * 1.4)
190
- + 0.5 * sin(dot(gWXZ, vec2(-0.11, 0.09)) + uGrassTime * 2.3);
191
- float gAmp = (0.6 + 0.4 * gFlow) * gw * uGrassWind;
192
- float gph = uGrassTime * 2.2 + windPhase;
193
- vec2 gWdir = normalize(uGrassWindDir + 1e-4);
194
- transformed.x += (gWdir.x * gAmp) + sin(gph) * gw * 0.25 * uGrassWind;
195
- transformed.z += (gWdir.y * gAmp) + cos(gph * 0.7) * gw * 0.25 * uGrassWind;
196
- // Player/actor bend: radial push AWAY from each nearby bender's XZ position, same tip-weighted
197
- // falloff (gw, 0 at base / max at tip) as the wind sway above so blades pivot from their planted
198
- // base rather than translating whole -- and springs back to upright the instant a bender's
199
- // distance exceeds uGrassBendRadius (a pure per-frame function of live bender position, no
200
- // stored/animated spring state needed: the blade IS upright whenever no bender is close, and
201
- // smoothstep gives a soft, non-snappy edge rather than a hard cutoff).
202
- vec2 bendXZ = vec2(0.0);
203
- for (int bi = 0; bi < ${MAX_BENDERS}; bi++) {
204
- if (bi >= uBenderCount) break;
205
- vec2 toBlade = gWXZ - uBenderPosXZ[bi];
206
- float bd = length(toBlade);
207
- float bInfluence = 1.0 - smoothstep(0.0, uGrassBendRadius, bd);
208
- if (bInfluence > 0.0) {
209
- vec2 bDir = bd > 1e-4 ? toBlade / bd : vec2(1.0, 0.0);
210
- bendXZ += bDir * bInfluence * uGrassBendStrength;
211
- }
212
- }
213
- transformed.x += bendXZ.x * gw;
214
- transformed.z += bendXZ.y * gw;
215
- // Bent blades lean rather than stretch: pull the tip down proportional to how far it swept
216
- // sideways, same small-angle approximation as a rigid pivot (keeps blade length ~constant).
217
- transformed.y -= length(bendXZ) * gw * 0.35;
218
- // Burn/flatten decal: unlike the bend loop above (a radial push), scorch is a pure SCALE-DOWN
219
- // (shorter/thinner blade) + tint shift toward uGrassScorchColor -- no directional displacement,
220
- // since a scorched patch has no "away from" direction the way a walked-through blade does.
221
- // Per-decal falloff uses each stamp's own radius (smoothstep, soft edge matching GrassDecal.js's
222
- // cosine-falloff intent closely enough for a cheap GPU approximation), strength scales its peak.
223
- float scorch = 0.0;
224
- for (int di = 0; di < ${MAX_DECALS}; di++) {
225
- if (di >= uDecalCount) break;
226
- vec4 dc = uDecalPosXZRS[di];
227
- float dRadius = dc.z;
228
- if (dRadius <= 0.0) continue;
229
- float dd = distance(gWXZ, dc.xy);
230
- float dInfluence = (1.0 - smoothstep(0.0, dRadius, dd)) * clamp(dc.w, 0.0, 1.0);
231
- scorch = max(scorch, dInfluence);
232
- }
233
- vScorch = scorch;
234
- float scorchScale = mix(1.0, uGrassScorchShrink, scorch);
235
- transformed.y *= scorchScale; transformed.x *= scorchScale; transformed.z *= scorchScale;
236
- float gDist = length(gWXZ - uCamPosXZ);
237
- float gFade = 1.0 - smoothstep(uGrassRing * 0.7, uGrassRing, gDist);
238
- transformed.y *= gFade; transformed.x *= mix(0.5, 1.0, gFade); transformed.z *= mix(0.5, 1.0, gFade);
239
- // flatten toward up, same 60% blend as before, computed once here (object-space, cheap) and
240
- // carried to the fragment stage as a varying instead of touched per-fragment
241
- vec3 flatNormal = normalize(mix(normalize(normal), vec3(0.0, 1.0, 0.0), 0.6));
242
- vWorldNormal = normalize(mat3(instanceMatrix) * mat3(modelMatrix) * flatNormal);
243
- vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(transformed, 1.0);
244
- gl_Position = projectionMatrix * mvPosition;
245
- }
246
- `,
247
- fragmentShader: `
248
- uniform vec3 uSunDir, uSunColor, uAmbient, uGrassScorchColor;
249
- varying float vGrassY, vTint, vInstShadow, vScorch;
250
- varying vec3 vWorldNormal;
251
- void main() {
252
- vec3 gLo = vec3(0.12,0.22,0.06), gHi = mix(vec3(0.34,0.55,0.16), vec3(0.45,0.5,0.14), vTint);
253
- float gAO = 0.6 + 0.4 * smoothstep(0.0, 0.2, vGrassY);
254
- vec3 baseColor = mix(gLo, gHi, clamp(vGrassY, 0.0, 1.0)) * gAO * 2.0;
255
- // Scorch tint: blend toward the dry/burnt color at full decal influence, same vScorch scalar
256
- // that already shrank blade scale in the vertex stage.
257
- baseColor = mix(baseColor, uGrassScorchColor, vScorch);
258
- // gl_FrontFacing flip: with FrontSide-only draw the two crossed quads still need a lit back
259
- // face when viewed from behind, so mirror the normal instead of relying on a second draw pass.
260
- vec3 n = gl_FrontFacing ? vWorldNormal : -vWorldNormal;
261
- // cheap Lambert-ish diffuse + baked/approximate AO, no GGX specular lobe, no env IBL sample --
262
- // the flattened normal already means a full PBR BRDF evaluation would mostly reduce to this.
263
- float ndl = max(dot(n, uSunDir), 0.0);
264
- // per-instance cached terrain-shadow value stands in for a real shadow-map PCF fetch
265
- vec3 lit = baseColor * (uAmbient + uSunColor * ndl * vInstShadow);
266
- gl_FragColor = vec4(lit, 1.0);
267
- }
268
- `
269
- })
270
- material.customProgramCacheKey = () => 'grassblade-lambert'
271
- return material
272
- }
273
-
274
28
  export async function createGrass(opts = {}) {
275
29
  const { renderer, scene, frame } = opts
276
30
  // Client-visual paint-biome sync (terrain-paint-biome-client-visual-sync) -- see the matching comment
@@ -0,0 +1,256 @@
1
+ // Grass blade geometry + wind/material factories for Grass.js's createGrass(). Pure builders --
2
+ // no per-instance streaming/chunk-management state, split out as that file's largest contiguous
3
+ // self-contained block. See Grass.js's own header for the full LOD/bend/decal design rationale.
4
+
5
+ import * as THREE from 'three'
6
+
7
+ // Player/actor bend: a small fixed-size array of nearby world-space XZ positions the vertex shader
8
+ // pushes blades radially away from (base-anchored, same tip-weighted falloff as the wind bend below),
9
+ // springing back upright once a bender leaves each blade's influence radius. MAX_BENDERS caps the
10
+ // uniform array size (and the per-frame CPU cost of building it) -- grass render distance is tens of
11
+ // metres, so a handful of nearby players/actors is the realistic max ever influencing one visible
12
+ // blade simultaneously; RenderGraph.nodes.js's foliage-lod-sync feeds only the nearest MAX_BENDERS
13
+ // actors within grassBendRadius of the local player, sorted by distance, so the cap silently degrades
14
+ // (farthest excess actors just don't bend grass) rather than ever overflowing the array.
15
+ export const MAX_BENDERS = 8
16
+
17
+ // Burn/flatten decals: a small fixed-size array of nearby scorch-stamp centers (world-space XZ, same
18
+ // space as uBenderPosXZ above), each with its own radius+strength, that the vertex shader shrinks and
19
+ // re-tints blades within. Distinct from the bender system above: benders are TRANSIENT (rebuilt fresh
20
+ // every frame from live actor positions, zero persisted state, springs back the instant an actor
21
+ // leaves), decals are PERSISTENT (backed by src/terrain/GrassDecal.js's sparse cell-Map world-state
22
+ // store -- once markScorched is called the effect stays until an explicit clear/regrowth, independent
23
+ // of any actor being nearby). MAX_DECALS caps the uniform array + per-frame nearest-stamp scan cost,
24
+ // same rationale as MAX_BENDERS: grass render distance is tens of metres so only a handful of decals
25
+ // are ever in view at once; nearestStamps() silently degrades (farthest excess decals just don't
26
+ // apply) rather than overflowing.
27
+ export const MAX_DECALS = 8
28
+
29
+ // 2 crossed tapered triangles (one along x, one along z), base at y=0, tip at y=1 (scaled per instance).
30
+ // Multi-segment curved ribbon blade (bends along its length under wind), two crossed quads for silhouette volume, one shared geometry across all instances.
31
+ // N is parameterized for the 2-geometry-tier LOD built in createGrass (Grass.js): N=5 near (curved, 20
32
+ // tris/blade), N=1 mid (flat crossed quad, 4 tris/blade -- the curve term still bakes in via the
33
+ // tip-only bend so a 1-segment blade still leans, it just can't bow mid-blade). Beyond the mid tier's
34
+ // cutoff blade curvature is genuinely invisible (sub-pixel at >15m per the PRD row); the THIRD (far) tier
35
+ // named by the task is deliberately the existing chunk-unload boundary + vertex-shader ring-fade, not a
36
+ // third real geometry -- see Grass.js's createGrass LOD block comment for the full scope-choice rationale.
37
+ export function makeBladeGeo(segments) {
38
+ const N = Number.isFinite(segments) && segments >= 1 ? segments | 0 : 5
39
+ const wBase = 0.07, curve = 0.18 // base half-width, baked forward arc (m at tip)
40
+ const pos = [], idx = []
41
+ const quads = [[[-1, 0], [1, 0]], [[0, -1], [0, 1]]]
42
+ let vi = 0
43
+ for (const [a, b] of quads) {
44
+ for (let s = 0; s <= N; s++) {
45
+ const v = s / N
46
+ const w = wBase * (1 - v)
47
+ const bend = curve * v * v
48
+ pos.push(a[0] * w + bend, v, a[1] * w, b[0] * w + bend, v, b[1] * w)
49
+ }
50
+ for (let s = 0; s < N; s++) {
51
+ const r0 = vi + s * 2, r1 = r0 + 2
52
+ idx.push(r0, r0 + 1, r1, r0 + 1, r1 + 1, r1)
53
+ }
54
+ vi += (N + 1) * 2
55
+ }
56
+ const g = new THREE.BufferGeometry()
57
+ g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pos), 3))
58
+ g.setIndex(idx)
59
+ g.computeVertexNormals()
60
+ g.computeBoundingSphere(); g.computeBoundingBox()
61
+ return g
62
+ }
63
+
64
+ export function makeWind() {
65
+ return {
66
+ uGrassTime: { value: 0 }, uGrassWind: { value: 1 }, uGrassWindDir: { value: new THREE.Vector2(0.8, 0.6) },
67
+ uCamPosXZ: { value: new THREE.Vector2(0, 0) }, uGrassRing: { value: 44 },
68
+ uSunDir: { value: new THREE.Vector3(0.4, 0.8, 0.3).normalize() }, uSunColor: { value: new THREE.Color(1, 1, 0.96) },
69
+ uAmbient: { value: new THREE.Color(0.32, 0.36, 0.4) },
70
+ // uBenderPosXZ: MAX_BENDERS packed (x,z) pairs (world XZ, same space as instanceMatrix). Unused
71
+ // slots hold a position far outside any real chunk so their falloff term is always ~0 (cheaper than
72
+ // branching per-slot in the shader). uBenderCount lets the loop skip empty slots outright.
73
+ uBenderPosXZ: { value: new Float32Array(MAX_BENDERS * 2).fill(1e6) },
74
+ uBenderCount: { value: 0 },
75
+ uGrassBendRadius: { value: 2.2 },
76
+ uGrassBendStrength: { value: 1.4 },
77
+ // uDecalPosXZRS: MAX_DECALS packed (x,z,radius,strength) quads. Unused slots hold radius=0 so their
78
+ // influence term is always exactly 0 regardless of position (cheaper/safer than a sentinel-distance
79
+ // trick since radius, not distance, gates the falloff here). uDecalCount lets the loop skip empty
80
+ // slots outright, same pattern as uBenderCount.
81
+ uDecalPosXZRS: { value: new Float32Array(MAX_DECALS * 4) },
82
+ uDecalCount: { value: 0 },
83
+ uGrassScorchShrink: { value: 0.15 }, // blade scale multiplier at full scorch influence (near-flattened, not fully zero -- a scorched patch still has stubble)
84
+ uGrassScorchColor: { value: new THREE.Color(0.22, 0.15, 0.06) }, // dry/burnt tint blended in at full influence
85
+ }
86
+ }
87
+
88
+ // Hand-written Lambert-ish ShaderMaterial: replaces MeshStandardMaterial's full PBR (GGX specular,
89
+ // env IBL, real shadow-map PCF) with the cheap lighting model grass actually needs -- the fragment
90
+ // shader already discards most of the PBR output by overriding color and flattening the normal 60%
91
+ // toward up, so paying for GGX/IBL/PCF per fragment across tens of thousands of overlapping blades was
92
+ // pure waste. FrontSide only (was DoubleSide): back faces are flipped via gl_FrontFacing in the
93
+ // fragment stage instead of being drawn a second time, halving rasterized fragments for away-facing
94
+ // blades while keeping the same two-crossed-quad silhouette. No alphaTest (blades are geometric, not
95
+ // alpha-cutout) and no real shadow-map sampling (see uInstShadow -- a per-instance cached terrain-shadow
96
+ // scalar set once per blade instance, sampled in the vertex shader, never a per-fragment PCF fetch).
97
+ export function makeGrassMaterial(wind) {
98
+ const material = new THREE.ShaderMaterial({
99
+ uniforms: {
100
+ uGrassTime: wind.uGrassTime,
101
+ uGrassWind: wind.uGrassWind,
102
+ uGrassWindDir: wind.uGrassWindDir,
103
+ uCamPosXZ: wind.uCamPosXZ,
104
+ uGrassRing: wind.uGrassRing,
105
+ uSunDir: wind.uSunDir,
106
+ uSunColor: wind.uSunColor,
107
+ uAmbient: wind.uAmbient,
108
+ uBenderPosXZ: wind.uBenderPosXZ,
109
+ uBenderCount: wind.uBenderCount,
110
+ uGrassBendRadius: wind.uGrassBendRadius,
111
+ uGrassBendStrength: wind.uGrassBendStrength,
112
+ uDecalPosXZRS: wind.uDecalPosXZRS,
113
+ uDecalCount: wind.uDecalCount,
114
+ uGrassScorchShrink: wind.uGrassScorchShrink,
115
+ uGrassScorchColor: wind.uGrassScorchColor
116
+ },
117
+ side: THREE.FrontSide,
118
+ // no alphaTest: blades are opaque triangle geometry, alphaTest would defeat early-Z for nothing gained
119
+ vertexShader: `
120
+ uniform float uGrassTime, uGrassWind, uGrassRing;
121
+ uniform vec2 uGrassWindDir, uCamPosXZ;
122
+ uniform vec2 uBenderPosXZ[${MAX_BENDERS}];
123
+ uniform int uBenderCount;
124
+ uniform float uGrassBendRadius, uGrassBendStrength;
125
+ // uDecalPosXZRS: packed (x,z,radius,strength) per decal -- burn/flatten world-state, see
126
+ // src/terrain/GrassDecal.js. Unlike the bender loop above (radius is a single shared uniform),
127
+ // each decal carries its OWN radius+strength since real-world stamps (a small vehicle track vs a
128
+ // large explosion crater) vary in both.
129
+ uniform vec4 uDecalPosXZRS[${MAX_DECALS}];
130
+ uniform int uDecalCount;
131
+ uniform float uGrassScorchShrink;
132
+ uniform vec3 uGrassScorchColor;
133
+ // windPhase/tint/instShadow are NOT declared here -- InstancedMesh2.initUniformsPerInstance's
134
+ // material patch (wrapping this material's onBeforeCompile/customProgramCacheKey, see Uniforms.js
135
+ // + SquareDataTexture.getUniformsVertexGLSL) injects their float name; global declarations and
136
+ // per-instance texel-fetch assignment itself, ahead of this shader's own void main() body.
137
+ // instShadow: per-instance cached terrain-shadow scalar (0=fully shadowed .. 1=fully lit), set once
138
+ // per blade instance at placement time from the terrain-slope self-shadow approximation in
139
+ // src/terrain/GrassPlacement.js -- never a real per-fragment shadow-map PCF fetch.
140
+ //
141
+ // instancedmesh2-instanceindex-undeclared-identifier-vegetation-shader: InstancedMesh2's per-instance
142
+ // uniform injection (the windPhase/instShadow/tint texel-fetch above) ALSO needs the instanceIndex
143
+ // vertex attribute in scope -- normally provided for free by THREE's own ShaderLib templates via
144
+ // '#include <batching_pars_vertex>' (which @three.ez's ShaderChunk.js concatenates its own
145
+ // instanced_pars_vertex chunk onto), but this is a hand-written raw ShaderMaterial with NEITHER
146
+ // include, so instanceIndex was genuinely undeclared -- real live GL compile failure caught via a
147
+ // WebGL2RenderingContext.prototype.compileShader monkeypatch (ERROR 0:86/0:177 'instanceIndex' :
148
+ // undeclared identifier), reproduced live at PORT=8250 after ~29s of real gameplay streaming grass
149
+ // chunks in. '#include <instanced_pars_vertex>' (resolved by THREE's own resolveIncludes, which runs
150
+ // on every material's final shader string, ShaderMaterial included) declares BOTH instanceIndex and
151
+ // getInstancedMatrix(). This InstancedMesh2 also always sets USE_INSTANCING_INDIRECT (see
152
+ // InstancedMesh2.js _onBeforeCompile), which makes the raw instanceMatrix ATTRIBUTE a dummy
153
+ // zero-length buffer (the real per-instance matrix lives in matricesTexture instead) -- so every
154
+ // pre-existing raw instanceMatrix read below was ALSO silently wrong (would have rendered
155
+ // degenerate/zeroed blade transforms once the instanceIndex fix alone made this shader compile);
156
+ // fixed by locally shadowing instanceMatrix with the real computed matrix, the same pattern THREE's
157
+ // own instanced_vertex chunk uses for its built-in ShaderLib materials.
158
+ varying float vGrassY, vTint, vInstShadow, vScorch;
159
+ varying vec3 vWorldNormal;
160
+ #include <common>
161
+ #include <instanced_pars_vertex>
162
+ void main() {
163
+ #ifdef USE_INSTANCING_INDIRECT
164
+ mat4 instanceMatrix = getInstancedMatrix();
165
+ #endif
166
+ vGrassY = position.y;
167
+ vTint = tint;
168
+ vInstShadow = instShadow;
169
+ vec3 transformed = position;
170
+ vec2 gWXZ = instanceMatrix[3].xz;
171
+ float gv = clamp(position.y, 0.0, 1.0);
172
+ float gw = gv * gv * 0.45;
173
+ float gFlow = sin(dot(gWXZ, vec2(0.06, 0.045)) + uGrassTime * 1.4)
174
+ + 0.5 * sin(dot(gWXZ, vec2(-0.11, 0.09)) + uGrassTime * 2.3);
175
+ float gAmp = (0.6 + 0.4 * gFlow) * gw * uGrassWind;
176
+ float gph = uGrassTime * 2.2 + windPhase;
177
+ vec2 gWdir = normalize(uGrassWindDir + 1e-4);
178
+ transformed.x += (gWdir.x * gAmp) + sin(gph) * gw * 0.25 * uGrassWind;
179
+ transformed.z += (gWdir.y * gAmp) + cos(gph * 0.7) * gw * 0.25 * uGrassWind;
180
+ // Player/actor bend: radial push AWAY from each nearby bender's XZ position, same tip-weighted
181
+ // falloff (gw, 0 at base / max at tip) as the wind sway above so blades pivot from their planted
182
+ // base rather than translating whole -- and springs back to upright the instant a bender's
183
+ // distance exceeds uGrassBendRadius (a pure per-frame function of live bender position, no
184
+ // stored/animated spring state needed: the blade IS upright whenever no bender is close, and
185
+ // smoothstep gives a soft, non-snappy edge rather than a hard cutoff).
186
+ vec2 bendXZ = vec2(0.0);
187
+ for (int bi = 0; bi < ${MAX_BENDERS}; bi++) {
188
+ if (bi >= uBenderCount) break;
189
+ vec2 toBlade = gWXZ - uBenderPosXZ[bi];
190
+ float bd = length(toBlade);
191
+ float bInfluence = 1.0 - smoothstep(0.0, uGrassBendRadius, bd);
192
+ if (bInfluence > 0.0) {
193
+ vec2 bDir = bd > 1e-4 ? toBlade / bd : vec2(1.0, 0.0);
194
+ bendXZ += bDir * bInfluence * uGrassBendStrength;
195
+ }
196
+ }
197
+ transformed.x += bendXZ.x * gw;
198
+ transformed.z += bendXZ.y * gw;
199
+ // Bent blades lean rather than stretch: pull the tip down proportional to how far it swept
200
+ // sideways, same small-angle approximation as a rigid pivot (keeps blade length ~constant).
201
+ transformed.y -= length(bendXZ) * gw * 0.35;
202
+ // Burn/flatten decal: unlike the bend loop above (a radial push), scorch is a pure SCALE-DOWN
203
+ // (shorter/thinner blade) + tint shift toward uGrassScorchColor -- no directional displacement,
204
+ // since a scorched patch has no "away from" direction the way a walked-through blade does.
205
+ // Per-decal falloff uses each stamp's own radius (smoothstep, soft edge matching GrassDecal.js's
206
+ // cosine-falloff intent closely enough for a cheap GPU approximation), strength scales its peak.
207
+ float scorch = 0.0;
208
+ for (int di = 0; di < ${MAX_DECALS}; di++) {
209
+ if (di >= uDecalCount) break;
210
+ vec4 dc = uDecalPosXZRS[di];
211
+ float dRadius = dc.z;
212
+ if (dRadius <= 0.0) continue;
213
+ float dd = distance(gWXZ, dc.xy);
214
+ float dInfluence = (1.0 - smoothstep(0.0, dRadius, dd)) * clamp(dc.w, 0.0, 1.0);
215
+ scorch = max(scorch, dInfluence);
216
+ }
217
+ vScorch = scorch;
218
+ float scorchScale = mix(1.0, uGrassScorchShrink, scorch);
219
+ transformed.y *= scorchScale; transformed.x *= scorchScale; transformed.z *= scorchScale;
220
+ float gDist = length(gWXZ - uCamPosXZ);
221
+ float gFade = 1.0 - smoothstep(uGrassRing * 0.7, uGrassRing, gDist);
222
+ transformed.y *= gFade; transformed.x *= mix(0.5, 1.0, gFade); transformed.z *= mix(0.5, 1.0, gFade);
223
+ // flatten toward up, same 60% blend as before, computed once here (object-space, cheap) and
224
+ // carried to the fragment stage as a varying instead of touched per-fragment
225
+ vec3 flatNormal = normalize(mix(normalize(normal), vec3(0.0, 1.0, 0.0), 0.6));
226
+ vWorldNormal = normalize(mat3(instanceMatrix) * mat3(modelMatrix) * flatNormal);
227
+ vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(transformed, 1.0);
228
+ gl_Position = projectionMatrix * mvPosition;
229
+ }
230
+ `,
231
+ fragmentShader: `
232
+ uniform vec3 uSunDir, uSunColor, uAmbient, uGrassScorchColor;
233
+ varying float vGrassY, vTint, vInstShadow, vScorch;
234
+ varying vec3 vWorldNormal;
235
+ void main() {
236
+ vec3 gLo = vec3(0.12,0.22,0.06), gHi = mix(vec3(0.34,0.55,0.16), vec3(0.45,0.5,0.14), vTint);
237
+ float gAO = 0.6 + 0.4 * smoothstep(0.0, 0.2, vGrassY);
238
+ vec3 baseColor = mix(gLo, gHi, clamp(vGrassY, 0.0, 1.0)) * gAO * 2.0;
239
+ // Scorch tint: blend toward the dry/burnt color at full decal influence, same vScorch scalar
240
+ // that already shrank blade scale in the vertex stage.
241
+ baseColor = mix(baseColor, uGrassScorchColor, vScorch);
242
+ // gl_FrontFacing flip: with FrontSide-only draw the two crossed quads still need a lit back
243
+ // face when viewed from behind, so mirror the normal instead of relying on a second draw pass.
244
+ vec3 n = gl_FrontFacing ? vWorldNormal : -vWorldNormal;
245
+ // cheap Lambert-ish diffuse + baked/approximate AO, no GGX specular lobe, no env IBL sample --
246
+ // the flattened normal already means a full PBR BRDF evaluation would mostly reduce to this.
247
+ float ndl = max(dot(n, uSunDir), 0.0);
248
+ // per-instance cached terrain-shadow value stands in for a real shadow-map PCF fetch
249
+ vec3 lit = baseColor * (uAmbient + uSunColor * ndl * vInstShadow);
250
+ gl_FragColor = vec4(lit, 1.0);
251
+ }
252
+ `
253
+ })
254
+ material.customProgramCacheKey = () => 'grassblade-lambert'
255
+ return material
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.656",
3
+ "version": "0.1.658",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [