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.
- package/client/core/Grass.js +5 -251
- package/client/core/GrassMaterial.js +256 -0
- package/package.json +1 -1
package/client/core/Grass.js
CHANGED
|
@@ -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
|
+
}
|