@jgengine/shell 0.25.0 → 0.27.0
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/CHANGELOG.md +109 -0
- package/dist/GamePlayerShell.d.ts +8 -0
- package/dist/GamePlayerShell.js +15 -1
- package/dist/Shell3dPresentation.js +29 -11
- package/dist/devtools/FrameTelemetry.d.ts +12 -0
- package/dist/devtools/FrameTelemetry.js +68 -0
- package/dist/devtools/agentBridge.js +2 -0
- package/dist/devtools/devtoolsReports.d.ts +2 -0
- package/dist/devtools/devtoolsReports.js +3 -0
- package/dist/devtools/frameSampler.d.ts +127 -0
- package/dist/devtools/frameSampler.js +345 -0
- package/dist/environment/AuthoredDaylight.d.ts +28 -0
- package/dist/environment/AuthoredDaylight.js +42 -0
- package/dist/environment/Daylight.d.ts +55 -7
- package/dist/environment/Daylight.js +126 -29
- package/dist/environment/EnvironmentScene.js +51 -8
- package/dist/environment/daylightCycle.d.ts +26 -4
- package/dist/environment/daylightCycle.js +36 -5
- package/dist/postfx/PostProcessing.d.ts +7 -1
- package/dist/postfx/PostProcessing.js +28 -2
- package/dist/postfx/autoExposure.d.ts +49 -0
- package/dist/postfx/autoExposure.js +189 -0
- package/dist/postfx/gradeShader.js +43 -4
- package/dist/presentationRecovery3d.js +2 -1
- package/dist/render/CascadedShadows.d.ts +3 -1
- package/dist/render/CascadedShadows.js +9 -1
- package/dist/render/EnvironmentLighting.d.ts +10 -1
- package/dist/render/EnvironmentLighting.js +11 -3
- package/dist/render/IrradianceVolumeLighting.d.ts +18 -0
- package/dist/render/IrradianceVolumeLighting.js +35 -0
- package/dist/render/SceneLighting.d.ts +14 -1
- package/dist/render/SceneLighting.js +29 -8
- package/dist/render/authoredGrade.d.ts +11 -0
- package/dist/render/authoredGrade.js +26 -0
- package/dist/render/decalGeometry.d.ts +26 -0
- package/dist/render/decalGeometry.js +54 -0
- package/dist/render/decalTexture.d.ts +13 -0
- package/dist/render/decalTexture.js +114 -0
- package/dist/render/modelWind.d.ts +28 -0
- package/dist/render/modelWind.js +103 -0
- package/dist/render/presentationLook.d.ts +15 -0
- package/dist/render/presentationLook.js +18 -0
- package/dist/render/stableTextureSet.d.ts +9 -0
- package/dist/render/stableTextureSet.js +24 -0
- package/dist/render/triplanarMaterial.d.ts +52 -0
- package/dist/render/triplanarMaterial.js +169 -0
- package/dist/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +3 -1
- package/dist/scatter/scatterModels.js +2 -0
- package/dist/scene/DecalRenderer.d.ts +2 -0
- package/dist/scene/DecalRenderer.js +74 -0
- package/dist/scene/GrassFieldRenderer.js +3 -2
- package/dist/scene/builtinSceneKindRenderers.js +2 -0
- package/dist/settings/appliedSettings.js +16 -13
- package/dist/terrain/GrassCover.d.ts +21 -0
- package/dist/terrain/GrassCover.js +84 -0
- package/dist/terrain/grassCoverGeometry.d.ts +40 -0
- package/dist/terrain/grassCoverGeometry.js +112 -0
- package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
- package/dist/terrain/terrainDetailMaterial.js +54 -1
- package/dist/terrain/terrainSurfaces.d.ts +6 -0
- package/dist/terrain/terrainSurfaces.js +10 -0
- package/dist/world/WorldScene.js +2 -1
- package/package.json +4 -4
- package/skills/jgengine/api.md +59 -6
- package/skills/jgengine/capabilities.md +4 -0
- package/skills/jgengine-ui/api.md +114 -23
- package/skills/jgengine-ui/capabilities.md +14 -0
- package/skills/jgengine-ui/reference-primitives.md +4 -2
- package/skills/jgengine-world/SKILL.md +7 -1
- package/skills/jgengine-world/api.md +222 -8
- package/skills/jgengine-world/capabilities.md +42 -2
- package/skills/jgengine-world/recipes/npc-senses.md +8 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import * as THREE from "three";
|
|
4
|
+
import { useMaterialAssetMaterial } from "./materialAsset.js";
|
|
5
|
+
const VERTEX_PARS = `varying vec3 vJgTriPos;
|
|
6
|
+
varying vec3 vJgTriNormal;`;
|
|
7
|
+
const VERTEX_WORLD_NORMAL = `vJgTriNormal = normalize(mat3(modelMatrix) * objectNormal);`;
|
|
8
|
+
const VERTEX_WORLD_POSITION = `vec4 jgTriWorld = vec4(transformed, 1.0);
|
|
9
|
+
#ifdef USE_INSTANCING
|
|
10
|
+
jgTriWorld = instanceMatrix * jgTriWorld;
|
|
11
|
+
#endif
|
|
12
|
+
vJgTriPos = (modelMatrix * jgTriWorld).xyz;`;
|
|
13
|
+
const FRAGMENT_PARS = `varying vec3 vJgTriPos;
|
|
14
|
+
varying vec3 vJgTriNormal;
|
|
15
|
+
uniform float uJgTriTile;
|
|
16
|
+
uniform float uJgTriSharp;
|
|
17
|
+
uniform float uJgTriNormalScale;
|
|
18
|
+
vec3 jgTriWeights() {
|
|
19
|
+
vec3 w = pow(abs(normalize(vJgTriNormal)), vec3(uJgTriSharp));
|
|
20
|
+
return w / max(dot(w, vec3(1.0)), 1e-4);
|
|
21
|
+
}
|
|
22
|
+
vec3 jgTriSample(sampler2D tex, vec3 w) {
|
|
23
|
+
vec3 p = vJgTriPos / uJgTriTile;
|
|
24
|
+
return texture2D(tex, p.zy).rgb * w.x + texture2D(tex, p.xz).rgb * w.y + texture2D(tex, p.xy).rgb * w.z;
|
|
25
|
+
}`;
|
|
26
|
+
const FRAGMENT_COLOR = `#ifdef USE_MAP
|
|
27
|
+
{
|
|
28
|
+
vec3 jgW = jgTriWeights();
|
|
29
|
+
vec3 jgP = vJgTriPos / uJgTriTile;
|
|
30
|
+
diffuseColor *= texture2D(map, jgP.zy) * jgW.x + texture2D(map, jgP.xz) * jgW.y + texture2D(map, jgP.xy) * jgW.z;
|
|
31
|
+
}
|
|
32
|
+
#endif`;
|
|
33
|
+
const FRAGMENT_ROUGHNESS = `float roughnessFactor = roughness;
|
|
34
|
+
#ifdef USE_ROUGHNESSMAP
|
|
35
|
+
roughnessFactor *= jgTriSample(roughnessMap, jgTriWeights()).g;
|
|
36
|
+
#endif`;
|
|
37
|
+
// Each projection perturbs the world normal along its own tangent frame: an X-facing projection
|
|
38
|
+
// uses +Z as U and +Y as V, a Y-facing one +X/+Z, a Z-facing one +X/+Y.
|
|
39
|
+
const FRAGMENT_NORMAL = `#ifdef USE_NORMALMAP
|
|
40
|
+
{
|
|
41
|
+
vec3 jgW = jgTriWeights();
|
|
42
|
+
vec3 jgP = vJgTriPos / uJgTriTile;
|
|
43
|
+
vec3 jgNx = texture2D(normalMap, jgP.zy).xyz * 2.0 - 1.0;
|
|
44
|
+
vec3 jgNy = texture2D(normalMap, jgP.xz).xyz * 2.0 - 1.0;
|
|
45
|
+
vec3 jgNz = texture2D(normalMap, jgP.xy).xyz * 2.0 - 1.0;
|
|
46
|
+
jgNx.xy *= uJgTriNormalScale;
|
|
47
|
+
jgNy.xy *= uJgTriNormalScale;
|
|
48
|
+
jgNz.xy *= uJgTriNormalScale;
|
|
49
|
+
vec3 jgPerturb = vec3(jgNx.z, jgNx.y, jgNx.x) * jgW.x
|
|
50
|
+
+ vec3(jgNy.x, jgNy.z, jgNy.y) * jgW.y
|
|
51
|
+
+ vec3(jgNz.x, jgNz.y, jgNz.z) * jgW.z;
|
|
52
|
+
normal = normalize((viewMatrix * vec4(normalize(vJgTriNormal + jgPerturb), 0.0)).xyz);
|
|
53
|
+
}
|
|
54
|
+
#endif`;
|
|
55
|
+
const FRAGMENT_AO = `#ifdef USE_AOMAP
|
|
56
|
+
{
|
|
57
|
+
float jgAo = jgTriSample(aoMap, jgTriWeights()).r;
|
|
58
|
+
float ambientOcclusion = ( jgAo - 1.0 ) * aoMapIntensity + 1.0;
|
|
59
|
+
reflectedLight.indirectDiffuse *= ambientOcclusion;
|
|
60
|
+
#if defined( USE_CLEARCOAT )
|
|
61
|
+
clearcoatSpecularIndirect *= ambientOcclusion;
|
|
62
|
+
#endif
|
|
63
|
+
#if defined( USE_SHEEN )
|
|
64
|
+
sheenSpecularIndirect *= ambientOcclusion;
|
|
65
|
+
#endif
|
|
66
|
+
#if defined( USE_ENVMAP ) && defined( STANDARD )
|
|
67
|
+
float dotNV = saturate( dot( geometryNormal, geometryViewDir ) );
|
|
68
|
+
reflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, ambientOcclusion, material.roughness );
|
|
69
|
+
#endif
|
|
70
|
+
}
|
|
71
|
+
#endif`;
|
|
72
|
+
/**
|
|
73
|
+
* The GLSL the triplanar path injects, split by chunk. Exposed so a test can prove the patch
|
|
74
|
+
* samples by world position instead of `vMapUv`, and so a reader can audit the projection.
|
|
75
|
+
* @internal
|
|
76
|
+
*/
|
|
77
|
+
export function triplanarChunks() {
|
|
78
|
+
return {
|
|
79
|
+
vertexPars: VERTEX_PARS,
|
|
80
|
+
vertexNormal: VERTEX_WORLD_NORMAL,
|
|
81
|
+
vertexPosition: VERTEX_WORLD_POSITION,
|
|
82
|
+
fragmentPars: FRAGMENT_PARS,
|
|
83
|
+
color: FRAGMENT_COLOR,
|
|
84
|
+
roughness: FRAGMENT_ROUGHNESS,
|
|
85
|
+
normal: FRAGMENT_NORMAL,
|
|
86
|
+
ao: FRAGMENT_AO,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function assertPatchable(material) {
|
|
90
|
+
const vertex = THREE.ShaderLib.physical.vertexShader;
|
|
91
|
+
const fragment = THREE.ShaderLib.physical.fragmentShader;
|
|
92
|
+
const required = [
|
|
93
|
+
[vertex, "#include <beginnormal_vertex>"],
|
|
94
|
+
[vertex, "#include <worldpos_vertex>"],
|
|
95
|
+
[fragment, "#include <map_fragment>"],
|
|
96
|
+
[fragment, "#include <roughnessmap_fragment>"],
|
|
97
|
+
[fragment, "#include <normal_fragment_maps>"],
|
|
98
|
+
[fragment, "#include <aomap_fragment>"],
|
|
99
|
+
];
|
|
100
|
+
for (const [source, marker] of required) {
|
|
101
|
+
if (!source.includes(marker))
|
|
102
|
+
throw new Error(`Three.js r${THREE.REVISION} physical shader lacks ${marker} for the triplanar world projection`);
|
|
103
|
+
}
|
|
104
|
+
if (!material.isMeshStandardMaterial)
|
|
105
|
+
throw new Error("Triplanar projection requires a standard or physical material");
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Projects a standard material's maps from world position instead of mesh UVs, so world geometry
|
|
109
|
+
* without a `uv` attribute still renders a textured PBR surface. Instanced meshes project in their
|
|
110
|
+
* instanced world space. Skinned meshes keep the bind-pose normal for the blend weights, which is
|
|
111
|
+
* close enough for the static world surfaces this path exists for.
|
|
112
|
+
*/
|
|
113
|
+
export function applyTriplanarProjection(material, options) {
|
|
114
|
+
const tile = options.tileMetres;
|
|
115
|
+
if (!Number.isFinite(tile) || tile <= 0)
|
|
116
|
+
throw new Error("Triplanar tileMetres must be finite and positive");
|
|
117
|
+
const sharpness = options.sharpness ?? 8;
|
|
118
|
+
const normalScale = options.normalScale ?? 1;
|
|
119
|
+
if (!Number.isFinite(sharpness) || sharpness <= 0)
|
|
120
|
+
throw new Error("Triplanar sharpness must be finite and positive");
|
|
121
|
+
if (!Number.isFinite(normalScale) || normalScale < 0)
|
|
122
|
+
throw new Error("Triplanar normalScale must be finite and non-negative");
|
|
123
|
+
if (material.userData["jgTriplanar"] !== undefined)
|
|
124
|
+
return material;
|
|
125
|
+
assertPatchable(material);
|
|
126
|
+
const uniforms = {
|
|
127
|
+
uJgTriTile: { value: tile },
|
|
128
|
+
uJgTriSharp: { value: sharpness },
|
|
129
|
+
uJgTriNormalScale: { value: normalScale },
|
|
130
|
+
};
|
|
131
|
+
material.userData["jgTriplanar"] = uniforms;
|
|
132
|
+
const previousCompile = material.onBeforeCompile;
|
|
133
|
+
const previousKey = material.customProgramCacheKey;
|
|
134
|
+
material.onBeforeCompile = (shader, renderer) => {
|
|
135
|
+
previousCompile.call(material, shader, renderer);
|
|
136
|
+
Object.assign(shader.uniforms, uniforms);
|
|
137
|
+
shader.vertexShader = shader.vertexShader
|
|
138
|
+
.replace("#include <common>", `#include <common>\n${VERTEX_PARS}`)
|
|
139
|
+
.replace("#include <beginnormal_vertex>", `#include <beginnormal_vertex>\n${VERTEX_WORLD_NORMAL}`)
|
|
140
|
+
.replace("#include <worldpos_vertex>", `#include <worldpos_vertex>\n${VERTEX_WORLD_POSITION}`);
|
|
141
|
+
shader.fragmentShader = shader.fragmentShader
|
|
142
|
+
.replace("#include <common>", `#include <common>\n${FRAGMENT_PARS}`)
|
|
143
|
+
.replace("#include <map_fragment>", FRAGMENT_COLOR)
|
|
144
|
+
.replace("#include <roughnessmap_fragment>", FRAGMENT_ROUGHNESS)
|
|
145
|
+
.replace("#include <normal_fragment_maps>", FRAGMENT_NORMAL)
|
|
146
|
+
.replace("#include <aomap_fragment>", FRAGMENT_AO);
|
|
147
|
+
};
|
|
148
|
+
material.customProgramCacheKey = () => `${previousKey.call(material)}|jg-triplanar:v1`;
|
|
149
|
+
material.needsUpdate = true;
|
|
150
|
+
return material;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Render a serializable material asset — one built by `libraryMaterialAsset`, or authored in the
|
|
154
|
+
* editor — with world-space triplanar projection, so world geometry needs no UVs. Tile size
|
|
155
|
+
* defaults to the asset's own `physicalSize`, which the shared library sets per surface.
|
|
156
|
+
* @capability material-triplanar render a material asset on world geometry without UVs
|
|
157
|
+
*/
|
|
158
|
+
export function useTriplanarAssetMaterial(asset, options = {}) {
|
|
159
|
+
const material = useMaterialAssetMaterial(asset);
|
|
160
|
+
const tile = options.tileMetres ?? asset.textures?.color?.physicalSize?.[0] ?? asset.textures?.normal?.physicalSize?.[0] ?? 1;
|
|
161
|
+
const sharpness = options.sharpness ?? 8;
|
|
162
|
+
const normalScale = options.normalScale ?? 1;
|
|
163
|
+
return useMemo(() => applyTriplanarProjection(material, { tileMetres: tile, sharpness, normalScale }), [material, tile, sharpness, normalScale]);
|
|
164
|
+
}
|
|
165
|
+
/** Mounts {@link useTriplanarAssetMaterial} on a mesh; the R3F renderer owns disposal. */
|
|
166
|
+
export function TriplanarAssetMaterial({ asset, ...options }) {
|
|
167
|
+
const material = useTriplanarAssetMaterial(asset, options);
|
|
168
|
+
return _jsx("primitive", { object: material, attach: "material" });
|
|
169
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Group, type Object3D } from "three";
|
|
2
2
|
import type { ModelConfig } from "@jgengine/core/game/playableGame";
|
|
3
3
|
/** Model loading, placement, shadows and animation for a custom renderer. */
|
|
4
|
-
export type ModelInstanceConfig = Pick<ModelConfig, "url" | "scale" | "targetHeight" | "y" | "anchor" | "dims" | "shadows" | "animation" | "hiddenNodes" | "surfaceDetail">;
|
|
4
|
+
export type ModelInstanceConfig = Pick<ModelConfig, "url" | "scale" | "targetHeight" | "y" | "anchor" | "dims" | "shadows" | "animation" | "hiddenNodes" | "surfaceDetail" | "wind">;
|
|
5
5
|
/** Caller-owned styling runs on the isolated clone, never the loader cache. */
|
|
6
6
|
export interface ModelInstanceOptions {
|
|
7
7
|
instanceId?: string;
|
|
@@ -5,6 +5,7 @@ import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility
|
|
|
5
5
|
import { measureLocalBounds } from "./measureBounds.js";
|
|
6
6
|
import { detectKtx2Support, sharedGltfLoader } from "./modelLoad.js";
|
|
7
7
|
import { cloneModelScene, disposeModelScene, modelPlacementTransform } from "./modelRender.js";
|
|
8
|
+
import { applyModelWindToScene } from "./modelWind.js";
|
|
8
9
|
import { useModelAnimation } from "./useModelAnimation.js";
|
|
9
10
|
import { applyModelNodeVisibility, modelHiddenNodesKey } from "./modelNodeVisibility.js";
|
|
10
11
|
/**
|
|
@@ -26,6 +27,7 @@ export function useModelInstance(model, options = {}) {
|
|
|
26
27
|
const content = cloneModelScene(gltf.scene, { shadows: model.shadows, surfaceDetail: model.surfaceDetail });
|
|
27
28
|
try {
|
|
28
29
|
configure?.(content);
|
|
30
|
+
applyModelWindToScene(content, model.wind, model.wind === false || model.wind === undefined ? 4 : model.wind.height ?? 4);
|
|
29
31
|
for (const diagnostic of applyModelNodeVisibility(content, hiddenNodes))
|
|
30
32
|
console.warn(`Model ${model.url} hiddenNodes: ${diagnostic.message}`);
|
|
31
33
|
const scene = new Group().add(content);
|
|
@@ -35,7 +37,7 @@ export function useModelInstance(model, options = {}) {
|
|
|
35
37
|
disposeModelScene(content);
|
|
36
38
|
throw error;
|
|
37
39
|
}
|
|
38
|
-
}, [gltf.scene, model.shadows, model.surfaceDetail, configure, hiddenNodesKey]);
|
|
40
|
+
}, [gltf.scene, model.shadows, model.surfaceDetail, model.wind, configure, hiddenNodesKey]);
|
|
39
41
|
const placement = useMemo(() => modelPlacementTransform(scene, model, bindBounds), [scene, bindBounds, model.scale, model.targetHeight, model.y, model.anchor, model.dims]);
|
|
40
42
|
useEffect(() => () => disposeModelScene(content), [content]);
|
|
41
43
|
useModelAnimation(content, gltf.animations, model.animation, instanceId);
|
|
@@ -3,6 +3,7 @@ import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility
|
|
|
3
3
|
import { applyMaterialOverride } from "../materialOverride.js";
|
|
4
4
|
import { cloneModelScene, disposeModelScene, ownModelMaterial } from "../render/modelRender.js";
|
|
5
5
|
import { applyModelSurface } from "../render/modelSurface.js";
|
|
6
|
+
import { applyModelWindToScene } from "../render/modelWind.js";
|
|
6
7
|
/** The scale + position offset a `ModelConfig` applies before any per-instance transform — same anchor/targetHeight rules as the single-instance entity renderer. */
|
|
7
8
|
function scatterModelBaseTransform(scene, model) {
|
|
8
9
|
let scale = model.scale ?? 1;
|
|
@@ -43,6 +44,7 @@ export function buildScatterModelSources(gltfScene, model) {
|
|
|
43
44
|
throw new Error("Instanced model override maps require an owned texture loader; use EntityModel for authored maps");
|
|
44
45
|
applyModelSurface(gltfScene);
|
|
45
46
|
const root = cloneModelScene(gltfScene, { shadows: model.shadows, surfaceDetail: model.surfaceDetail });
|
|
47
|
+
applyModelWindToScene(root, model.wind, model.dims?.maxY ?? model.targetHeight ?? 4);
|
|
46
48
|
if (model.material !== undefined)
|
|
47
49
|
applyMaterialOverride(root, model.material, { clone: false, ownMaterial: material => ownModelMaterial(root, material) });
|
|
48
50
|
root.updateMatrixWorld(true);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import * as THREE from "three";
|
|
4
|
+
import { resolveDecalObject } from "@jgengine/core/world/decalKind";
|
|
5
|
+
import { createDecalDrapeGeometry } from "../render/decalGeometry.js";
|
|
6
|
+
import { createDecalTexture } from "../render/decalTexture.js";
|
|
7
|
+
import { useDisposable } from "../render/useDisposable.js";
|
|
8
|
+
import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
|
|
9
|
+
/** Tilts at or above this stand the decal up on a wall instead of draping it on the ground. */
|
|
10
|
+
const WALL_TILT = 45;
|
|
11
|
+
function dataTexture(style, seed) {
|
|
12
|
+
const baked = createDecalTexture(style, seed);
|
|
13
|
+
const texture = new THREE.DataTexture(baked.data, baked.size, baked.size, THREE.RGBAFormat, THREE.UnsignedByteType);
|
|
14
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
15
|
+
texture.magFilter = THREE.LinearFilter;
|
|
16
|
+
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
17
|
+
texture.generateMipmaps = true;
|
|
18
|
+
texture.wrapS = THREE.ClampToEdgeWrapping;
|
|
19
|
+
texture.wrapT = THREE.ClampToEdgeWrapping;
|
|
20
|
+
texture.needsUpdate = true;
|
|
21
|
+
return texture;
|
|
22
|
+
}
|
|
23
|
+
function OneDecal({ object, context }) {
|
|
24
|
+
const resolved = useMemo(() => resolveDecalObject(object), [object]);
|
|
25
|
+
const rules = resolved?.rules;
|
|
26
|
+
const style = rules?.style ?? "dirt";
|
|
27
|
+
const seed = rules?.seed ?? "";
|
|
28
|
+
const texture = useDisposable(() => dataTexture(style, seed), [style, seed]);
|
|
29
|
+
const wall = (rules?.tilt ?? 0) >= WALL_TILT;
|
|
30
|
+
const geometry = useDisposable(() => {
|
|
31
|
+
if (resolved === null || rules === undefined)
|
|
32
|
+
return new THREE.BufferGeometry();
|
|
33
|
+
if (wall)
|
|
34
|
+
return new THREE.PlaneGeometry(rules.size, rules.size);
|
|
35
|
+
const draped = createDecalDrapeGeometry({
|
|
36
|
+
size: rules.size,
|
|
37
|
+
rotationY: resolved.rotationY,
|
|
38
|
+
heightAt: context.field.sampleHeight,
|
|
39
|
+
lift: rules.lift,
|
|
40
|
+
});
|
|
41
|
+
const next = new THREE.BufferGeometry();
|
|
42
|
+
next.setAttribute("position", new THREE.BufferAttribute(draped.positions, 3));
|
|
43
|
+
next.setAttribute("normal", new THREE.BufferAttribute(draped.normals, 3));
|
|
44
|
+
next.setAttribute("uv", new THREE.BufferAttribute(draped.uvs, 2));
|
|
45
|
+
next.setIndex(new THREE.BufferAttribute(draped.indices, 1));
|
|
46
|
+
next.computeBoundingSphere();
|
|
47
|
+
return next;
|
|
48
|
+
}, [resolved, rules, wall, context.field]);
|
|
49
|
+
const material = useDisposable(() => new THREE.MeshStandardMaterial({
|
|
50
|
+
map: texture,
|
|
51
|
+
transparent: true,
|
|
52
|
+
depthWrite: false,
|
|
53
|
+
opacity: rules?.opacity ?? 1,
|
|
54
|
+
roughness: 0.92,
|
|
55
|
+
metalness: 0,
|
|
56
|
+
polygonOffset: true,
|
|
57
|
+
polygonOffsetFactor: -4,
|
|
58
|
+
polygonOffsetUnits: -4,
|
|
59
|
+
}), [texture, rules?.opacity]);
|
|
60
|
+
if (resolved === null || rules === undefined)
|
|
61
|
+
return null;
|
|
62
|
+
const [px, py, pz] = resolved.position;
|
|
63
|
+
if (!wall) {
|
|
64
|
+
return _jsx("mesh", { geometry: geometry, material: material, position: [px, py, pz], receiveShadow: true, frustumCulled: true });
|
|
65
|
+
}
|
|
66
|
+
// A standing decal is pushed out along its own normal so it clears the wall it describes.
|
|
67
|
+
const normalX = Math.sin(resolved.rotationY);
|
|
68
|
+
const normalZ = Math.cos(resolved.rotationY);
|
|
69
|
+
return (_jsx("mesh", { geometry: geometry, material: material, position: [px + normalX * rules.offset, py + rules.lift, pz + normalZ * rules.offset], rotation: [THREE.MathUtils.degToRad(rules.tilt - 90), resolved.rotationY, 0], receiveShadow: true, frustumCulled: true }));
|
|
70
|
+
}
|
|
71
|
+
/** Registers the `decal` marker renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
|
|
72
|
+
export function registerDecalRenderer() {
|
|
73
|
+
registerSceneKindRenderer("decal", ({ objects, context }) => (_jsx(_Fragment, { children: objects.map((object) => (_jsx(OneDecal, { object: object, context: context }, object.id))) })));
|
|
74
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useMemo } from "react";
|
|
3
3
|
import { resolveGrassObject } from "@jgengine/core/world/grassKind";
|
|
4
4
|
import { clearanceMasksFrom, DEFAULT_CLEARANCE_KINDS } from "@jgengine/core/world/scatterRegion";
|
|
5
5
|
import { masksInfluence } from "@jgengine/core/world/geometry";
|
|
6
6
|
import { GrassField } from "../terrain/GrassField.js";
|
|
7
|
+
import { GrassCover } from "../terrain/GrassCover.js";
|
|
7
8
|
import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
|
|
8
9
|
/** Volume kinds turf yields to — blades never grow through cracked soil or out of open water. */
|
|
9
10
|
const GRASS_YIELDS_TO = new Set(["soil", "water"]);
|
|
@@ -86,7 +87,7 @@ function OneGrass({ object, context }) {
|
|
|
86
87
|
// footprint, and the fade only thins how many *draw* at range in the vertex shader — so a deeper
|
|
87
88
|
// meadow reads expansive without raising the instance ceiling.
|
|
88
89
|
const instanceCount = Math.max(1500, Math.min(250000, Math.ceil(rules.density * area[0] * area[1])));
|
|
89
|
-
return (_jsx(GrassField, { position: [center[0], center[1], center[2]], area: area, count: instanceCount, density: rules.density, bladeHeight: bladeHeight, bladeWidth: bladeWidth, bladeBend: bladeBend, tuftRadius: rules.tuftRadius, normalLift: rules.normalLift, colorBase: rules.colorBase, colorTip: rules.colorTip, colorGround: context.groundColorAt?.(center[0], center[2]), colorVariation: rules.colorVariation, roughness: rules.roughness, seed: hashSeed(rules.seed.length > 0 ? rules.seed : object.id), heightAt: heightAt, coverageAt: coverageAt, wind: wind, edgeFeather: 2.2, exclude: exclude }));
|
|
90
|
+
return (_jsxs(_Fragment, { children: [_jsx(GrassCover, { position: [center[0], center[1], center[2]], area: area, seed: hashSeed(rules.seed.length > 0 ? rules.seed : object.id), colorBase: rules.colorBase, colorTip: rules.colorTip, strength: rules.groundCover, edgeFeather: 2.2, heightAt: heightAt, coverageAt: coverageAt }), _jsx(GrassField, { position: [center[0], center[1], center[2]], area: area, count: instanceCount, density: rules.density, bladeHeight: bladeHeight, bladeWidth: bladeWidth, bladeBend: bladeBend, tuftRadius: rules.tuftRadius, normalLift: rules.normalLift, colorBase: rules.colorBase, colorTip: rules.colorTip, colorGround: context.groundColorAt?.(center[0], center[2]), colorVariation: rules.colorVariation, roughness: rules.roughness, seed: hashSeed(rules.seed.length > 0 ? rules.seed : object.id), heightAt: heightAt, coverageAt: coverageAt, wind: wind, edgeFeather: 2.2, exclude: exclude })] }));
|
|
90
91
|
}
|
|
91
92
|
/** Registers the `grass_field` volume renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
|
|
92
93
|
export function registerGrassFieldRenderer() {
|
|
@@ -11,6 +11,7 @@ import { registerWaterRenderer } from "./WaterRenderer.js";
|
|
|
11
11
|
import { registerSoilPatchRenderer } from "./SoilPatchRenderer.js";
|
|
12
12
|
import { registerCityRenderer } from "./CityRenderer.js";
|
|
13
13
|
import { registerPoleLineRenderer } from "./PoleLineRenderer.js";
|
|
14
|
+
import { registerDecalRenderer } from "./DecalRenderer.js";
|
|
14
15
|
let registered = false;
|
|
15
16
|
/** Registers the engine's built-in environment scene-kind renderers. Called by `AuthoredScene`. @internal */
|
|
16
17
|
export function registerBuiltinSceneKindRenderers() {
|
|
@@ -23,4 +24,5 @@ export function registerBuiltinSceneKindRenderers() {
|
|
|
23
24
|
registerSoilPatchRenderer();
|
|
24
25
|
registerCityRenderer();
|
|
25
26
|
registerPoleLineRenderer();
|
|
27
|
+
registerDecalRenderer();
|
|
26
28
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
2
|
import { busVolumeSettingId, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
|
|
3
3
|
import { readGraphicsSettings } from "@jgengine/core/settings/graphicsSettings";
|
|
4
4
|
import { readFrameRateLimit } from "@jgengine/core/settings/frameRateLimit";
|
|
@@ -29,18 +29,21 @@ export function useSettingsRevision(store) {
|
|
|
29
29
|
return useSyncExternalStore(subscribe, getVersion, getVersion);
|
|
30
30
|
}
|
|
31
31
|
export function useGraphicsSettings(store, shadowsDefault, overrides) {
|
|
32
|
-
useSettingsRevision(store);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
32
|
+
const rev = useSettingsRevision(store);
|
|
33
|
+
// Effects rebuild on identity change; a fresh object each render recompiled the post chain every frame.
|
|
34
|
+
return useMemo(() => {
|
|
35
|
+
const { quality, profile } = readGraphicsSettings(store, overrides);
|
|
36
|
+
const rawUiScale = store.get(SETTING_IDS.graphicsUiScale, DEFAULT_UI_SCALE);
|
|
37
|
+
return {
|
|
38
|
+
shadows: store.get(SETTING_IDS.graphicsShadows, shadowsDefault),
|
|
39
|
+
dpr: profile.renderScale,
|
|
40
|
+
uiScale: Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, rawUiScale)),
|
|
41
|
+
quality,
|
|
42
|
+
profile,
|
|
43
|
+
frameRateLimit: readFrameRateLimit(store),
|
|
44
|
+
showFps: store.get(SETTING_IDS.graphicsShowFps, false),
|
|
45
|
+
};
|
|
46
|
+
}, [store, rev, shadowsDefault, overrides]);
|
|
44
47
|
}
|
|
45
48
|
export function AudioSettingsBridge({ store, engine, buses, }) {
|
|
46
49
|
const rev = useSettingsRevision(store);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { TerrainHeightSampler } from "./terrainMath.js";
|
|
3
|
+
/** Props for {@link GrassCover}: one authored patch's footprint, colours and drape samplers. */
|
|
4
|
+
export interface GrassCoverProps {
|
|
5
|
+
area: readonly [number, number];
|
|
6
|
+
seed: number;
|
|
7
|
+
colorBase: THREE.ColorRepresentation;
|
|
8
|
+
colorTip: THREE.ColorRepresentation;
|
|
9
|
+
strength: number;
|
|
10
|
+
edgeFeather: number;
|
|
11
|
+
heightAt: TerrainHeightSampler;
|
|
12
|
+
coverageAt: TerrainHeightSampler;
|
|
13
|
+
segments?: number;
|
|
14
|
+
position?: readonly [number, number, number];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The turf surface under one authored `grass_field` patch: a ground-draped, edge-feathered mat that
|
|
18
|
+
* turns a bare pavement footprint into grass the blade field stands in. Bounded to one draw and a
|
|
19
|
+
* {@link GRASS_COVER_MAX_SEGMENTS}² grid, so a large park still costs a single mesh.
|
|
20
|
+
*/
|
|
21
|
+
export declare function GrassCover({ area, seed, colorBase, colorTip, strength, edgeFeather, heightAt, coverageAt, segments, position, }: GrassCoverProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as THREE from "three";
|
|
3
|
+
import { useDisposable } from "../render/useDisposable.js";
|
|
4
|
+
import { createGrassCoverGeometry, GRASS_COVER_MAX_SEGMENTS } from "./grassCoverGeometry.js";
|
|
5
|
+
let grainTexture = null;
|
|
6
|
+
/**
|
|
7
|
+
* Tileable thatch grain for the cover mat (luminance around 0.9), generated once from a hash — no
|
|
8
|
+
* asset pull, deterministic, and cheap enough that a 96² mat still reads as grass up close.
|
|
9
|
+
*/
|
|
10
|
+
function coverGrain() {
|
|
11
|
+
if (grainTexture !== null)
|
|
12
|
+
return grainTexture;
|
|
13
|
+
const size = 64;
|
|
14
|
+
const data = new Uint8Array(size * size * 4);
|
|
15
|
+
const hash = (x, y) => {
|
|
16
|
+
let h = Math.imul(x + 1, 374761393) ^ Math.imul(y + 1, 668265263) ^ 0x9e3779b9;
|
|
17
|
+
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
18
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
19
|
+
};
|
|
20
|
+
for (let y = 0; y < size; y += 1) {
|
|
21
|
+
for (let x = 0; x < size; x += 1) {
|
|
22
|
+
const fine = (hash(x, y) + hash((x + 32) % size, (y + 17) % size)) * 0.5;
|
|
23
|
+
const clump = (hash((x >> 2) * 3, (y >> 2) * 5) + hash(((x >> 2) + 7) % 16, ((y >> 2) + 3) % 16)) * 0.5;
|
|
24
|
+
const value = Math.max(180, Math.min(255, Math.round(232 + (fine - 0.5) * 46 + (clump - 0.5) * 30)));
|
|
25
|
+
const i = (y * size + x) * 4;
|
|
26
|
+
data[i] = value;
|
|
27
|
+
data[i + 1] = value;
|
|
28
|
+
data[i + 2] = value;
|
|
29
|
+
data[i + 3] = 255;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
grainTexture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat, THREE.UnsignedByteType);
|
|
33
|
+
grainTexture.wrapS = THREE.RepeatWrapping;
|
|
34
|
+
grainTexture.wrapT = THREE.RepeatWrapping;
|
|
35
|
+
grainTexture.magFilter = THREE.LinearFilter;
|
|
36
|
+
grainTexture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
37
|
+
grainTexture.generateMipmaps = true;
|
|
38
|
+
grainTexture.needsUpdate = true;
|
|
39
|
+
return grainTexture;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The turf surface under one authored `grass_field` patch: a ground-draped, edge-feathered mat that
|
|
43
|
+
* turns a bare pavement footprint into grass the blade field stands in. Bounded to one draw and a
|
|
44
|
+
* {@link GRASS_COVER_MAX_SEGMENTS}² grid, so a large park still costs a single mesh.
|
|
45
|
+
*/
|
|
46
|
+
export function GrassCover({ area, seed, colorBase, colorTip, strength, edgeFeather, heightAt, coverageAt, segments, position, }) {
|
|
47
|
+
const geometry = useDisposable(() => {
|
|
48
|
+
const baked = createGrassCoverGeometry({
|
|
49
|
+
area,
|
|
50
|
+
seed,
|
|
51
|
+
colorBase,
|
|
52
|
+
colorTip,
|
|
53
|
+
strength,
|
|
54
|
+
edgeFeather,
|
|
55
|
+
heightAt,
|
|
56
|
+
coverageAt,
|
|
57
|
+
...(segments === undefined ? {} : { segments }),
|
|
58
|
+
});
|
|
59
|
+
const next = new THREE.BufferGeometry();
|
|
60
|
+
if (baked.positions.length === 0)
|
|
61
|
+
return next;
|
|
62
|
+
next.setAttribute("position", new THREE.BufferAttribute(baked.positions, 3));
|
|
63
|
+
next.setAttribute("normal", new THREE.BufferAttribute(baked.normals, 3));
|
|
64
|
+
next.setAttribute("color", new THREE.BufferAttribute(baked.colors, 4));
|
|
65
|
+
next.setAttribute("uv", new THREE.BufferAttribute(baked.uvs, 2));
|
|
66
|
+
next.setIndex(new THREE.BufferAttribute(baked.indices, 1));
|
|
67
|
+
next.computeBoundingSphere();
|
|
68
|
+
return next;
|
|
69
|
+
}, [area, seed, colorBase, colorTip, strength, edgeFeather, heightAt, coverageAt, segments]);
|
|
70
|
+
const material = useDisposable(() => new THREE.MeshStandardMaterial({
|
|
71
|
+
map: coverGrain(),
|
|
72
|
+
vertexColors: true,
|
|
73
|
+
transparent: true,
|
|
74
|
+
depthWrite: false,
|
|
75
|
+
roughness: 0.96,
|
|
76
|
+
metalness: 0,
|
|
77
|
+
polygonOffset: true,
|
|
78
|
+
polygonOffsetFactor: -1,
|
|
79
|
+
polygonOffsetUnits: -1,
|
|
80
|
+
}), []);
|
|
81
|
+
if (strength <= 0)
|
|
82
|
+
return null;
|
|
83
|
+
return (_jsx("mesh", { geometry: geometry, material: material, receiveShadow: true, frustumCulled: true, ...(position === undefined ? {} : { position: [position[0], position[1], position[2]] }) }));
|
|
84
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { type TerrainArea, type TerrainHeightSampler } from "./terrainMath.js";
|
|
3
|
+
/** World metres one cover-texture tile spans; the mat's own grain is sampled in world space. */
|
|
4
|
+
export declare const GRASS_COVER_TILE_METRES = 3;
|
|
5
|
+
/** Largest grid a cover mat bakes to, per axis — a 66 m park stays one draw at ~1 m cells. */
|
|
6
|
+
export declare const GRASS_COVER_MAX_SEGMENTS = 96;
|
|
7
|
+
/** Inputs for {@link createGrassCoverGeometry}. */
|
|
8
|
+
export interface GrassCoverGeometryOptions {
|
|
9
|
+
area: TerrainArea;
|
|
10
|
+
seed: number;
|
|
11
|
+
colorBase: THREE.ColorRepresentation;
|
|
12
|
+
colorTip: THREE.ColorRepresentation;
|
|
13
|
+
/** Ground-cover mat strength 0..1 (the authored `groundCover`); 0 produces no geometry. */
|
|
14
|
+
strength: number;
|
|
15
|
+
/** Meters of soft fringe at the patch border, matching the blade field's own feather. */
|
|
16
|
+
edgeFeather: number;
|
|
17
|
+
heightAt: TerrainHeightSampler;
|
|
18
|
+
coverageAt: TerrainHeightSampler;
|
|
19
|
+
/** Lift above the ground in meters so the mat never z-fights the terrain. Default 0.035. */
|
|
20
|
+
lift?: number;
|
|
21
|
+
/** Grid segments per axis; defaults to one per 2 m, clamped to {@link GRASS_COVER_MAX_SEGMENTS}. */
|
|
22
|
+
segments?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Bake result for {@link createGrassCoverGeometry}; empty arrays when the mat is switched off. */
|
|
25
|
+
export interface GrassCoverGeometry {
|
|
26
|
+
positions: Float32Array;
|
|
27
|
+
normals: Float32Array;
|
|
28
|
+
colors: Float32Array;
|
|
29
|
+
uvs: Float32Array;
|
|
30
|
+
indices: Uint32Array;
|
|
31
|
+
size: readonly [number, number];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Drapes an authored `grass_field` volume's footprint with a feathered turf mat: vertices follow the
|
|
35
|
+
* runtime ground, the patch edge fades out over `edgeFeather`, and authored clearance masks punch
|
|
36
|
+
* the cover back out where a path or footing crosses it. This is the surface under the blades, so a
|
|
37
|
+
* park reads as grass instead of bare pavement with stubble on it.
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
export declare function createGrassCoverGeometry(options: GrassCoverGeometryOptions): GrassCoverGeometry;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { resolveTerrainSize } from "./terrainMath.js";
|
|
3
|
+
/** World metres one cover-texture tile spans; the mat's own grain is sampled in world space. */
|
|
4
|
+
export const GRASS_COVER_TILE_METRES = 3;
|
|
5
|
+
/** Largest grid a cover mat bakes to, per axis — a 66 m park stays one draw at ~1 m cells. */
|
|
6
|
+
export const GRASS_COVER_MAX_SEGMENTS = 96;
|
|
7
|
+
function hash2(x, z, seed) {
|
|
8
|
+
let h = Math.imul(x | 0, 374761393) ^ Math.imul(z | 0, 668265263) ^ Math.imul(seed | 0, 2246822519);
|
|
9
|
+
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
10
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
11
|
+
}
|
|
12
|
+
function smooth(t) {
|
|
13
|
+
return t * t * (3 - 2 * t);
|
|
14
|
+
}
|
|
15
|
+
/** Bilinear value noise in world space; deterministic per seed, no allocation on the hot path. */
|
|
16
|
+
function valueNoise(x, z, seed) {
|
|
17
|
+
const xi = Math.floor(x);
|
|
18
|
+
const zi = Math.floor(z);
|
|
19
|
+
const tx = smooth(x - xi);
|
|
20
|
+
const tz = smooth(z - zi);
|
|
21
|
+
const a = hash2(xi, zi, seed);
|
|
22
|
+
const b = hash2(xi + 1, zi, seed);
|
|
23
|
+
const c = hash2(xi, zi + 1, seed);
|
|
24
|
+
const d = hash2(xi + 1, zi + 1, seed);
|
|
25
|
+
return (a + (b - a) * tx) * (1 - tz) + (c + (d - c) * tx) * tz;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Drapes an authored `grass_field` volume's footprint with a feathered turf mat: vertices follow the
|
|
29
|
+
* runtime ground, the patch edge fades out over `edgeFeather`, and authored clearance masks punch
|
|
30
|
+
* the cover back out where a path or footing crosses it. This is the surface under the blades, so a
|
|
31
|
+
* park reads as grass instead of bare pavement with stubble on it.
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export function createGrassCoverGeometry(options) {
|
|
35
|
+
const size = resolveTerrainSize(options.area);
|
|
36
|
+
const segments = Math.max(1, Math.min(GRASS_COVER_MAX_SEGMENTS, Math.floor(options.segments ?? Math.min(GRASS_COVER_MAX_SEGMENTS, Math.max(2, Math.round(Math.max(size.width, size.depth) / 2))))));
|
|
37
|
+
if (options.strength <= 0 || size.width <= 0 || size.depth <= 0) {
|
|
38
|
+
return { positions: new Float32Array(), normals: new Float32Array(), colors: new Float32Array(), uvs: new Float32Array(), indices: new Uint32Array(), size: [size.width, size.depth] };
|
|
39
|
+
}
|
|
40
|
+
const lift = options.lift ?? 0.035;
|
|
41
|
+
const halfW = size.width / 2;
|
|
42
|
+
const halfD = size.depth / 2;
|
|
43
|
+
const base = new THREE.Color(options.colorBase);
|
|
44
|
+
const tip = new THREE.Color(options.colorTip);
|
|
45
|
+
const root = base.clone().multiplyScalar(0.55);
|
|
46
|
+
const sheen = base.clone().multiplyScalar(0.98);
|
|
47
|
+
const cols = segments + 1;
|
|
48
|
+
const count = cols * cols;
|
|
49
|
+
const positions = new Float32Array(count * 3);
|
|
50
|
+
const normals = new Float32Array(count * 3);
|
|
51
|
+
const colors = new Float32Array(count * 4);
|
|
52
|
+
const uvs = new Float32Array(count * 2);
|
|
53
|
+
const eps = Math.max(0.25, Math.min(size.width, size.depth) / segments / 2);
|
|
54
|
+
for (let iz = 0; iz < cols; iz += 1) {
|
|
55
|
+
const z = -halfD + (iz / segments) * size.depth;
|
|
56
|
+
for (let ix = 0; ix < cols; ix += 1) {
|
|
57
|
+
const x = -halfW + (ix / segments) * size.width;
|
|
58
|
+
const index = iz * cols + ix;
|
|
59
|
+
positions[index * 3] = x;
|
|
60
|
+
positions[index * 3 + 1] = options.heightAt(x, z) + lift;
|
|
61
|
+
positions[index * 3 + 2] = z;
|
|
62
|
+
const hL = options.heightAt(x - eps, z);
|
|
63
|
+
const hR = options.heightAt(x + eps, z);
|
|
64
|
+
const hD = options.heightAt(x, z - eps);
|
|
65
|
+
const hU = options.heightAt(x, z + eps);
|
|
66
|
+
const nx = hL - hR;
|
|
67
|
+
const nz = hD - hU;
|
|
68
|
+
const ny = 2 * eps;
|
|
69
|
+
const length = Math.hypot(nx, ny, nz) || 1;
|
|
70
|
+
normals[index * 3] = nx / length;
|
|
71
|
+
normals[index * 3 + 1] = ny / length;
|
|
72
|
+
normals[index * 3 + 2] = nz / length;
|
|
73
|
+
const edge = Math.min(halfW - Math.abs(x), halfD - Math.abs(z));
|
|
74
|
+
const feather = Math.max(0.001, options.edgeFeather);
|
|
75
|
+
const edgeFade = smooth(Math.min(1, Math.max(0, edge / feather)));
|
|
76
|
+
const coverage = Math.max(0, Math.min(1, options.coverageAt(x, z)));
|
|
77
|
+
const grain = valueNoise(x / 1.7, z / 1.7, options.seed);
|
|
78
|
+
const patch = valueNoise(x / 11 + 31.7, z / 11 - 9.2, options.seed + 17);
|
|
79
|
+
const alpha = options.strength * edgeFade * coverage * (0.82 + 0.18 * grain);
|
|
80
|
+
// Macro patches lift the mat toward the blade tip hue and back to a rooted shade, so a park
|
|
81
|
+
// reads as turf with light and dry pockets instead of one flat green sheet.
|
|
82
|
+
const light = smooth(Math.min(1, Math.max(0, (patch - 0.25) / 0.6)));
|
|
83
|
+
const r = root.r + (sheen.r - root.r) * light;
|
|
84
|
+
const g = root.g + (sheen.g - root.g) * light;
|
|
85
|
+
const b = root.b + (sheen.b - root.b) * light;
|
|
86
|
+
const tipMix = grain * 0.3;
|
|
87
|
+
colors[index * 4] = r + (tip.r - r) * tipMix;
|
|
88
|
+
colors[index * 4 + 1] = g + (tip.g - g) * tipMix;
|
|
89
|
+
colors[index * 4 + 2] = b + (tip.b - b) * tipMix;
|
|
90
|
+
colors[index * 4 + 3] = alpha;
|
|
91
|
+
uvs[index * 2] = x / GRASS_COVER_TILE_METRES;
|
|
92
|
+
uvs[index * 2 + 1] = z / GRASS_COVER_TILE_METRES;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const indices = new Uint32Array(segments * segments * 6);
|
|
96
|
+
let cursor = 0;
|
|
97
|
+
for (let iz = 0; iz < segments; iz += 1) {
|
|
98
|
+
for (let ix = 0; ix < segments; ix += 1) {
|
|
99
|
+
const a = iz * cols + ix;
|
|
100
|
+
const b = a + 1;
|
|
101
|
+
const c = a + cols;
|
|
102
|
+
const d = c + 1;
|
|
103
|
+
indices[cursor++] = a;
|
|
104
|
+
indices[cursor++] = c;
|
|
105
|
+
indices[cursor++] = b;
|
|
106
|
+
indices[cursor++] = b;
|
|
107
|
+
indices[cursor++] = c;
|
|
108
|
+
indices[cursor++] = d;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { positions, normals, colors, uvs, indices, size: [size.width, size.depth] };
|
|
112
|
+
}
|