@jgengine/shell 0.11.0 → 0.12.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 +26 -0
- package/dist/CombatPresentation.d.ts +5 -0
- package/dist/CombatPresentation.js +8 -0
- package/dist/GameHost.d.ts +25 -1
- package/dist/GameHost.js +60 -4
- package/dist/GamePlayer.d.ts +5 -0
- package/dist/GamePlayer.js +5 -0
- package/dist/GamePlayerShell.d.ts +5 -0
- package/dist/GamePlayerShell.js +48 -358
- package/dist/GameUiPreview.js +3 -0
- package/dist/Shell3dPresentation.d.ts +63 -0
- package/dist/Shell3dPresentation.js +302 -0
- package/dist/ShellChrome.d.ts +51 -0
- package/dist/ShellChrome.js +51 -0
- package/dist/ShellHudPresentation.d.ts +41 -0
- package/dist/ShellHudPresentation.js +22 -0
- package/dist/audio/audioEngine.d.ts +4 -0
- package/dist/audio/audioEngine.js +111 -26
- package/dist/audio/audioWire.d.ts +22 -4
- package/dist/audio/audioWire.js +46 -3
- package/dist/audio/loopParams.d.ts +20 -0
- package/dist/audio/loopParams.js +28 -0
- package/dist/camera/GameCameraRig.js +6 -1
- package/dist/camera/cameraRigs.d.ts +3 -1
- package/dist/camera/cameraRigs.js +21 -3
- package/dist/camera/rigMath.d.ts +20 -0
- package/dist/camera/rigMath.js +29 -0
- package/dist/defineGame.d.ts +15 -1
- package/dist/defineGame.js +26 -4
- package/dist/devtools/DevtoolsOverlay.d.ts +4 -4
- package/dist/devtools/DevtoolsOverlay.js +28 -12
- package/dist/devtools/agentBridge.js +4 -0
- package/dist/devtools/perfDiagnose.js +1 -1
- package/dist/diagnostics/RuntimeDiagnostics.d.ts +7 -0
- package/dist/diagnostics/RuntimeDiagnostics.js +23 -12
- package/dist/environment/EnvironmentScene.js +12 -3
- package/dist/environment/terrainGroundColor.d.ts +9 -0
- package/dist/environment/terrainGroundColor.js +22 -0
- package/dist/gameKit.d.ts +27 -0
- package/dist/gameKit.js +29 -0
- package/dist/map/MapMarkerBeacons.d.ts +3 -2
- package/dist/postfx/PostProcessing.d.ts +9 -1
- package/dist/postfx/PostProcessing.js +11 -4
- package/dist/presentationResolve.d.ts +35 -0
- package/dist/presentationResolve.js +51 -0
- package/dist/registry.d.ts +1 -0
- package/dist/render/assetBase.d.ts +17 -0
- package/dist/render/assetBase.js +35 -0
- package/dist/render/modelLoad.js +4 -1
- package/dist/render/resolveModel.d.ts +15 -0
- package/dist/render/resolveModel.js +37 -2
- package/dist/scatter/InstancedScatter.js +5 -0
- package/dist/scatter/scatterProxies.js +24 -0
- package/dist/scene/AuthoredScene.d.ts +3 -1
- package/dist/scene/AuthoredScene.js +7 -2
- package/dist/scene/CityRenderer.d.ts +2 -0
- package/dist/scene/CityRenderer.js +1031 -0
- package/dist/scene/GrassFieldRenderer.js +53 -5
- package/dist/scene/PoleLineRenderer.d.ts +2 -0
- package/dist/scene/PoleLineRenderer.js +287 -0
- package/dist/scene/SoilPatchRenderer.js +70 -7
- package/dist/scene/WaterRenderer.js +34 -10
- package/dist/scene/builtinSceneKindRenderers.js +4 -0
- package/dist/scene/cityWindowMaterial.d.ts +16 -0
- package/dist/scene/cityWindowMaterial.js +81 -0
- package/dist/scene/sceneKindRenderers.d.ts +2 -0
- package/dist/settings/appliedSettings.d.ts +2 -1
- package/dist/settings/appliedSettings.js +4 -2
- package/dist/structures/TransformGizmo.d.ts +11 -1
- package/dist/structures/TransformGizmo.js +9 -5
- package/dist/terrain/GrassField.d.ts +11 -3
- package/dist/terrain/GrassField.js +16 -6
- package/dist/terrain/grassGeometry.d.ts +33 -1
- package/dist/terrain/grassGeometry.js +83 -21
- package/dist/terrain/grassMaterial.d.ts +18 -0
- package/dist/terrain/grassMaterial.js +66 -13
- package/dist/terrain/heightfieldGeometry.d.ts +34 -0
- package/dist/terrain/heightfieldGeometry.js +109 -0
- package/dist/terrain/index.d.ts +1 -0
- package/dist/terrain/index.js +1 -0
- package/dist/terrain/soilPatchMaterial.d.ts +15 -2
- package/dist/terrain/soilPatchMaterial.js +46 -8
- package/dist/terrain/terrainDetailMaterial.js +15 -2
- package/dist/water/Ocean.d.ts +7 -1
- package/dist/water/Ocean.js +16 -3
- package/dist/water/OceanConfig.d.ts +9 -0
- package/dist/water/OceanConfig.js +6 -0
- package/dist/water/OceanMaterial.d.ts +12 -0
- package/dist/water/OceanMaterial.js +9 -0
- package/dist/water/OceanShader.d.ts +2 -2
- package/dist/water/OceanShader.js +78 -10
- package/dist/world/PlaceScene.d.ts +11 -0
- package/dist/world/PlaceScene.js +27 -0
- package/dist/world/WorldScene.js +29 -2
- package/dist/worldSky.d.ts +7 -1
- package/dist/worldSky.js +11 -1
- package/package.json +4 -4
- package/skills/game-design/SKILL.md +28 -0
- package/skills/game-design/agents/openai.yaml +4 -0
- package/skills/game-design/references/application-playbook.md +170 -0
- package/skills/game-design/references/field-guide.md +288 -0
- package/skills/jgengine/SKILL.md +7 -4
- package/skills/jgengine/api.md +172 -79
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-ui/SKILL.md +15 -10
- package/skills/jgengine-ui/api.md +202 -47
- package/skills/jgengine-ui/capabilities.md +71 -2
- package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
- package/skills/jgengine-ui/reference.md +20 -7
- package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
- package/skills/jgengine-world/SKILL.md +13 -0
- package/skills/jgengine-world/api.md +419 -15
- package/skills/jgengine-world/capabilities.md +108 -2
- package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
- package/skills/jgengine-world/reference.md +43 -4
- package/skills/level-design/SKILL.md +54 -0
- package/skills/level-design/agents/openai.yaml +4 -0
- package/skills/level-design/references/application-playbook.md +192 -0
- package/skills/level-design/references/field-guide.md +337 -0
- package/skills/level-design/references/live-editor-loop.md +179 -0
- package/dist/cartridge/runtime.d.ts +0 -15
- package/dist/cartridge/runtime.js +0 -556
- package/dist/cartridge/spec.d.ts +0 -188
- package/dist/cartridge/spec.js +0 -19
- package/dist/cartridge/validate.d.ts +0 -2
- package/dist/cartridge/validate.js +0 -78
- package/dist/cartridge.d.ts +0 -145
- package/dist/cartridge.js +0 -246
- package/skills/jgengine/reference-cartridge.md +0 -72
|
@@ -1,8 +1,32 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo } from "react";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
3
|
import { resolveGrassObject } from "@jgengine/core/world/grassKind";
|
|
4
4
|
import { GrassField } from "../terrain/GrassField.js";
|
|
5
5
|
import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
|
|
6
|
+
/** Volume kinds turf yields to — blades never grow through cracked soil or out of open water. */
|
|
7
|
+
const GRASS_YIELDS_TO = new Set(["soil", "water"]);
|
|
8
|
+
/**
|
|
9
|
+
* Patch-local no-grow rects from the document's soil/water volumes. Water rects shrink by the
|
|
10
|
+
* shore margin so a fringe of blades still wades into the shallows like a real pond edge.
|
|
11
|
+
*/
|
|
12
|
+
function grassExclusionsFrom(context, center) {
|
|
13
|
+
const zones = [];
|
|
14
|
+
for (const volume of context.document.volumes) {
|
|
15
|
+
if (!GRASS_YIELDS_TO.has(volume.kind) || volume.halfExtents === undefined)
|
|
16
|
+
continue;
|
|
17
|
+
const shoreMargin = volume.kind === "water" ? 1.6 : 0;
|
|
18
|
+
const halfX = Math.max(0, volume.halfExtents.x - shoreMargin);
|
|
19
|
+
const halfZ = Math.max(0, volume.halfExtents.z - shoreMargin);
|
|
20
|
+
if (halfX <= 0 || halfZ <= 0)
|
|
21
|
+
continue;
|
|
22
|
+
zones.push({
|
|
23
|
+
center: [volume.center.x - center[0], volume.center.z - center[2]],
|
|
24
|
+
half: [halfX, halfZ],
|
|
25
|
+
feather: volume.kind === "water" ? 2.4 : 1.2,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return zones;
|
|
29
|
+
}
|
|
6
30
|
function hashSeed(text) {
|
|
7
31
|
let h = 2166136261 >>> 0;
|
|
8
32
|
for (let i = 0; i < text.length; i += 1) {
|
|
@@ -11,13 +35,37 @@ function hashSeed(text) {
|
|
|
11
35
|
}
|
|
12
36
|
return (h >>> 0) % 100000;
|
|
13
37
|
}
|
|
14
|
-
|
|
38
|
+
function sampleHeight(field, x, z) {
|
|
39
|
+
return field.sampleHeight(x, z);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* One authored grass volume → an instanced vertex-wind `GrassField` over its footprint.
|
|
43
|
+
* `area`/`bladeHeight`/`wind`/`heightAt` are memoized so `GrassField`'s internal geometry and
|
|
44
|
+
* material `useMemo`s see stable references across re-renders — otherwise every re-render (the
|
|
45
|
+
* live-sync document context object is recreated per render) rebuilds the blade geometry and
|
|
46
|
+
* shader material from scratch, which shows up as recurring render hitches.
|
|
47
|
+
*/
|
|
15
48
|
function OneGrass({ object, context }) {
|
|
16
49
|
const resolved = useMemo(() => resolveGrassObject(object), [object]);
|
|
17
|
-
|
|
50
|
+
const heightAt = useCallback((x, z) => sampleHeight(context.field, x, z), [context.field]);
|
|
51
|
+
const area = useMemo(() => (resolved === null ? null : [resolved.size[0], resolved.size[1]]), [resolved]);
|
|
52
|
+
const bladeHeight = useMemo(() => (resolved === null ? null : [resolved.rules.bladeHeight * 0.7, resolved.rules.bladeHeight]), [resolved]);
|
|
53
|
+
const wind = useMemo(() => resolved === null
|
|
54
|
+
? null
|
|
55
|
+
: { strength: resolved.rules.windStrength, speed: resolved.rules.windSpeed, gustScale: resolved.rules.windGust }, [resolved]);
|
|
56
|
+
// Depends on the volumes slice, not the context object — the live-sync context is recreated
|
|
57
|
+
// per render and would otherwise rebuild the tuft geometry every frame.
|
|
58
|
+
const volumes = context.document.volumes;
|
|
59
|
+
const exclude = useMemo(() => (resolved === null ? null : grassExclusionsFrom(context, resolved.center)),
|
|
60
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
|
+
[volumes, resolved]);
|
|
62
|
+
if (resolved === null || area === null || bladeHeight === null || wind === null || exclude === null)
|
|
18
63
|
return null;
|
|
19
|
-
const { rules,
|
|
20
|
-
|
|
64
|
+
const { rules, center } = resolved;
|
|
65
|
+
// Size the instance buffer to the authored patch (bounded): the default 1500-blade buffer reads
|
|
66
|
+
// as stubble on anything bigger than a lawn, silently ignoring the authored density.
|
|
67
|
+
const instanceCount = Math.max(1500, Math.min(250000, Math.ceil(rules.density * area[0] * area[1])));
|
|
68
|
+
return (_jsx(GrassField, { position: [center[0], center[1], center[2]], area: area, count: instanceCount, density: rules.density, bladeHeight: bladeHeight, 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, wind: wind, edgeFeather: 2.2, exclude: exclude }));
|
|
21
69
|
}
|
|
22
70
|
/** Registers the `grass_field` volume renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
|
|
23
71
|
export function registerGrassFieldRenderer() {
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Runtime renderer for the built-in `pole_line` scene kind: instanced poles (proxy cylinders, or a
|
|
4
|
+
* GLB pole asset when one is named) plus merged tube geometry for the sagging cables. Registered by
|
|
5
|
+
* `registerBuiltinSceneKindRenderers` so any document with a `pole_line` path renders with no game
|
|
6
|
+
* wiring. Uses the generic core primitive `readNamedSockets` to hang wires off a GLB pole's authored
|
|
7
|
+
* crossarm empties (falling back to computed offsets).
|
|
8
|
+
*/
|
|
9
|
+
import { Suspense, useEffect, useMemo, useRef } from "react";
|
|
10
|
+
import { useLoader } from "@react-three/fiber";
|
|
11
|
+
import * as THREE from "three";
|
|
12
|
+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
13
|
+
import { parseParams } from "@jgengine/core/scene/sceneKinds";
|
|
14
|
+
import { readNamedSockets } from "@jgengine/core/scene/modelSockets";
|
|
15
|
+
import { POLE_LINE_KIND, POLE_LINE_SCHEMA, resolvePoleLine } from "@jgengine/core/world/poleLineKind";
|
|
16
|
+
import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
|
|
17
|
+
function Cables({ cables }) {
|
|
18
|
+
const geometry = useMemo(() => {
|
|
19
|
+
const parts = [];
|
|
20
|
+
for (const cable of cables) {
|
|
21
|
+
if (cable.points.length < 2)
|
|
22
|
+
continue;
|
|
23
|
+
const curve = new THREE.CatmullRomCurve3(cable.points.map((point) => new THREE.Vector3(point[0], point[1], point[2])));
|
|
24
|
+
parts.push(new THREE.TubeGeometry(curve, Math.max(2, (cable.points.length - 1) * 2), cable.radius, 6, false));
|
|
25
|
+
}
|
|
26
|
+
if (parts.length === 0)
|
|
27
|
+
return null;
|
|
28
|
+
const merged = mergePositionGeometries(parts);
|
|
29
|
+
for (const part of parts)
|
|
30
|
+
part.dispose();
|
|
31
|
+
return merged;
|
|
32
|
+
}, [cables]);
|
|
33
|
+
useEffect(() => () => geometry?.dispose(), [geometry]);
|
|
34
|
+
if (geometry === null)
|
|
35
|
+
return null;
|
|
36
|
+
// Dark warm rubber-sheath tone with a hint of sheen — pitch black tubes read as ink scribbles
|
|
37
|
+
// against the sky, real cable catches a little light along its top.
|
|
38
|
+
return (_jsx("mesh", { geometry: geometry, castShadow: true, children: _jsx("meshStandardMaterial", { color: "#31281f", roughness: 0.55, metalness: 0.15 }) }));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Weathered wood for the proxy poles: vertical grain streaks + broad tonal banding driven by the
|
|
42
|
+
* pole's own cylindrical coordinates, offset per instance so no two poles share a pattern, and a
|
|
43
|
+
* sun-bleach lift toward the top. Injected via `onBeforeCompile`, instancing-aware.
|
|
44
|
+
*/
|
|
45
|
+
function createWeatheredWoodMaterial(color, grainAlong = "y") {
|
|
46
|
+
const material = new THREE.MeshStandardMaterial({ color, roughness: 0.92, metalness: 0 });
|
|
47
|
+
material.onBeforeCompile = (shader) => {
|
|
48
|
+
shader.vertexShader = shader.vertexShader
|
|
49
|
+
.replace("#include <common>", `#include <common>
|
|
50
|
+
varying vec3 vJgWoodLocal;
|
|
51
|
+
varying vec2 vJgWoodSeed;`)
|
|
52
|
+
.replace("#include <begin_vertex>", `#include <begin_vertex>
|
|
53
|
+
vJgWoodLocal = position;
|
|
54
|
+
#ifdef USE_INSTANCING
|
|
55
|
+
vJgWoodSeed = (instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xz;
|
|
56
|
+
#else
|
|
57
|
+
vJgWoodSeed = vec2(0.0);
|
|
58
|
+
#endif`);
|
|
59
|
+
shader.fragmentShader = shader.fragmentShader
|
|
60
|
+
.replace("#include <common>", `#include <common>
|
|
61
|
+
varying vec3 vJgWoodLocal;
|
|
62
|
+
varying vec2 vJgWoodSeed;
|
|
63
|
+
float jgWoodHash(vec2 p){
|
|
64
|
+
uvec2 q = uvec2(ivec2(floor(p)));
|
|
65
|
+
uint x = q.x * 1664525u + q.y * 1013904223u;
|
|
66
|
+
x = (x ^ (x >> 16u)) * 2246822519u;
|
|
67
|
+
x = (x ^ (x >> 13u)) * 3266489917u;
|
|
68
|
+
return float(x ^ (x >> 16u)) * (1.0 / 4294967296.0);
|
|
69
|
+
}
|
|
70
|
+
float jgWoodNoise(vec2 p){
|
|
71
|
+
vec2 i = floor(p); vec2 f = fract(p);
|
|
72
|
+
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
73
|
+
float a = jgWoodHash(i); float b = jgWoodHash(i + vec2(1.0, 0.0));
|
|
74
|
+
float c = jgWoodHash(i + vec2(0.0, 1.0)); float d = jgWoodHash(i + vec2(1.0, 1.0));
|
|
75
|
+
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
|
76
|
+
}`)
|
|
77
|
+
.replace("#include <color_fragment>", `#include <color_fragment>
|
|
78
|
+
${grainAlong === "y"
|
|
79
|
+
? `vec2 jgWoodUv = vec2(atan(vJgWoodLocal.x, vJgWoodLocal.z) * 1.4, vJgWoodLocal.y * 0.55);`
|
|
80
|
+
: `vec2 jgWoodUv = vec2(vJgWoodLocal.y * 9.0 + vJgWoodLocal.z * 7.0, vJgWoodLocal.x * 0.8);`}
|
|
81
|
+
vec2 jgWoodOff = vec2(dot(vJgWoodSeed, vec2(0.73, 1.31)), dot(vJgWoodSeed, vec2(1.17, 0.41)));
|
|
82
|
+
float jgWoodBand = jgWoodNoise(jgWoodUv * vec2(2.0, 1.0) + jgWoodOff);
|
|
83
|
+
float jgWoodStreak = jgWoodNoise(jgWoodUv * vec2(7.0, 0.35) + jgWoodOff * 1.7);
|
|
84
|
+
diffuseColor.rgb *= mix(0.74, 1.14, jgWoodBand * 0.45 + jgWoodStreak * 0.55);
|
|
85
|
+
${grainAlong === "y" ? `// Sun-bleached crown, damp-darkened foot — reads as years outdoors, not fresh paint.
|
|
86
|
+
diffuseColor.rgb = mix(diffuseColor.rgb * 0.82, diffuseColor.rgb * vec3(1.08, 1.05, 0.98), smoothstep(-3.5, 3.0, vJgWoodLocal.y));` : ""}`);
|
|
87
|
+
};
|
|
88
|
+
material.customProgramCacheKey = () => `jgengine-weathered-wood-${grainAlong}`;
|
|
89
|
+
return material;
|
|
90
|
+
}
|
|
91
|
+
/** Minimal position/normal/index merge — avoids depending on a specific BufferGeometryUtils path. */
|
|
92
|
+
function mergePositionGeometries(geometries) {
|
|
93
|
+
let vertexCount = 0;
|
|
94
|
+
let indexCount = 0;
|
|
95
|
+
for (const geometry of geometries) {
|
|
96
|
+
vertexCount += geometry.getAttribute("position").count;
|
|
97
|
+
const index = geometry.getIndex();
|
|
98
|
+
indexCount += index === null ? geometry.getAttribute("position").count : index.count;
|
|
99
|
+
}
|
|
100
|
+
const positions = new Float32Array(vertexCount * 3);
|
|
101
|
+
const normals = new Float32Array(vertexCount * 3);
|
|
102
|
+
const indices = new Uint32Array(indexCount);
|
|
103
|
+
let vertexOffset = 0;
|
|
104
|
+
let indexOffset = 0;
|
|
105
|
+
for (const geometry of geometries) {
|
|
106
|
+
const position = geometry.getAttribute("position");
|
|
107
|
+
const normal = geometry.getAttribute("normal");
|
|
108
|
+
positions.set(position.array, vertexOffset * 3);
|
|
109
|
+
if (normal !== undefined)
|
|
110
|
+
normals.set(normal.array, vertexOffset * 3);
|
|
111
|
+
const index = geometry.getIndex();
|
|
112
|
+
if (index === null) {
|
|
113
|
+
for (let i = 0; i < position.count; i += 1)
|
|
114
|
+
indices[indexOffset + i] = vertexOffset + i;
|
|
115
|
+
indexOffset += position.count;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
for (let i = 0; i < index.count; i += 1)
|
|
119
|
+
indices[indexOffset + i] = vertexOffset + index.getX(i);
|
|
120
|
+
indexOffset += index.count;
|
|
121
|
+
}
|
|
122
|
+
vertexOffset += position.count;
|
|
123
|
+
}
|
|
124
|
+
const merged = new THREE.BufferGeometry();
|
|
125
|
+
merged.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
|
126
|
+
merged.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
|
|
127
|
+
merged.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
128
|
+
return merged;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Proxy utility poles built from primitives when no GLB pole asset is named: a tapered trunk, a
|
|
132
|
+
* wood crossarm sized to the wire spread with a small brace, and one ceramic insulator peg under
|
|
133
|
+
* each wire offset — so the cables visibly hang *from* the pole instead of floating beside a stick.
|
|
134
|
+
*/
|
|
135
|
+
function ProxyPoles({ poles, height, wireCount, wireSpacing, }) {
|
|
136
|
+
const trunkRef = useRef(null);
|
|
137
|
+
const armRef = useRef(null);
|
|
138
|
+
const braceRef = useRef(null);
|
|
139
|
+
const insulatorRef = useRef(null);
|
|
140
|
+
const armLength = wireCount > 1 ? (wireCount - 1) * wireSpacing + 0.6 : 0.8;
|
|
141
|
+
// Chunkier members than a real-scale pole: stylized worlds read thin cylinders as wire, not wood.
|
|
142
|
+
const trunkGeometry = useMemo(() => new THREE.CylinderGeometry(0.13, 0.22, height, 10), [height]);
|
|
143
|
+
const armGeometry = useMemo(() => new THREE.BoxGeometry(armLength, 0.17, 0.14), [armLength]);
|
|
144
|
+
const braceGeometry = useMemo(() => {
|
|
145
|
+
const braceSpan = Math.hypot(armLength * 0.32, 0.7);
|
|
146
|
+
return new THREE.BoxGeometry(0.07, braceSpan, 0.06);
|
|
147
|
+
}, [armLength]);
|
|
148
|
+
const insulatorGeometry = useMemo(() => new THREE.CylinderGeometry(0.06, 0.085, 0.24, 8), []);
|
|
149
|
+
const trunkMaterial = useMemo(() => createWeatheredWoodMaterial("#6b533a"), []);
|
|
150
|
+
const armMaterial = useMemo(() => createWeatheredWoodMaterial("#59452e", "x"), []);
|
|
151
|
+
useEffect(() => () => {
|
|
152
|
+
trunkGeometry.dispose();
|
|
153
|
+
armGeometry.dispose();
|
|
154
|
+
braceGeometry.dispose();
|
|
155
|
+
insulatorGeometry.dispose();
|
|
156
|
+
}, [trunkGeometry, armGeometry, braceGeometry, insulatorGeometry]);
|
|
157
|
+
useEffect(() => () => {
|
|
158
|
+
trunkMaterial.dispose();
|
|
159
|
+
armMaterial.dispose();
|
|
160
|
+
}, [trunkMaterial, armMaterial]);
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const trunk = trunkRef.current;
|
|
163
|
+
const arm = armRef.current;
|
|
164
|
+
const brace = braceRef.current;
|
|
165
|
+
const insulator = insulatorRef.current;
|
|
166
|
+
if (trunk === null)
|
|
167
|
+
return;
|
|
168
|
+
const matrix = new THREE.Matrix4();
|
|
169
|
+
const position = new THREE.Vector3();
|
|
170
|
+
const quaternion = new THREE.Quaternion();
|
|
171
|
+
const euler = new THREE.Euler();
|
|
172
|
+
const scale = new THREE.Vector3(1, 1, 1);
|
|
173
|
+
const armY = height - 0.22;
|
|
174
|
+
const half = (wireCount - 1) / 2;
|
|
175
|
+
const braceAngle = Math.atan2(armLength * 0.32, 0.7);
|
|
176
|
+
poles.forEach((pole, index) => {
|
|
177
|
+
euler.set(0, pole.yaw, 0);
|
|
178
|
+
quaternion.setFromEuler(euler);
|
|
179
|
+
position.set(pole.position[0], pole.position[1] + height / 2, pole.position[2]);
|
|
180
|
+
matrix.compose(position, quaternion, scale);
|
|
181
|
+
trunk.setMatrixAt(index, matrix);
|
|
182
|
+
if (arm !== null) {
|
|
183
|
+
position.set(pole.position[0], pole.position[1] + armY, pole.position[2]);
|
|
184
|
+
matrix.compose(position, quaternion, scale);
|
|
185
|
+
arm.setMatrixAt(index, matrix);
|
|
186
|
+
}
|
|
187
|
+
if (brace !== null) {
|
|
188
|
+
// Two diagonal braces from the trunk up to the crossarm — the classic A-frame silhouette.
|
|
189
|
+
const cos = Math.cos(pole.yaw);
|
|
190
|
+
const sin = Math.sin(pole.yaw);
|
|
191
|
+
for (let side = 0; side < 2; side += 1) {
|
|
192
|
+
const direction = side === 0 ? 1 : -1;
|
|
193
|
+
const lateral = direction * armLength * 0.16;
|
|
194
|
+
position.set(pole.position[0] + cos * lateral, pole.position[1] + armY - 0.38, pole.position[2] - sin * lateral);
|
|
195
|
+
euler.set(0, pole.yaw, direction * braceAngle);
|
|
196
|
+
quaternion.setFromEuler(euler);
|
|
197
|
+
matrix.compose(position, quaternion, scale);
|
|
198
|
+
brace.setMatrixAt(index * 2 + side, matrix);
|
|
199
|
+
}
|
|
200
|
+
euler.set(0, pole.yaw, 0);
|
|
201
|
+
quaternion.setFromEuler(euler);
|
|
202
|
+
}
|
|
203
|
+
if (insulator !== null) {
|
|
204
|
+
// Insulator pegs sit on the crossarm at each wire's lateral offset (the pole's local X axis
|
|
205
|
+
// after yaw — the same perpendicular the resolver strings cables along).
|
|
206
|
+
const cos = Math.cos(pole.yaw);
|
|
207
|
+
const sin = Math.sin(pole.yaw);
|
|
208
|
+
for (let w = 0; w < wireCount; w += 1) {
|
|
209
|
+
const offset = (w - half) * wireSpacing;
|
|
210
|
+
position.set(pole.position[0] + cos * offset, pole.position[1] + height - 0.08, pole.position[2] - sin * offset);
|
|
211
|
+
matrix.compose(position, quaternion, scale);
|
|
212
|
+
insulator.setMatrixAt(index * wireCount + w, matrix);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
trunk.count = poles.length;
|
|
217
|
+
trunk.instanceMatrix.needsUpdate = true;
|
|
218
|
+
trunk.computeBoundingSphere();
|
|
219
|
+
if (arm !== null) {
|
|
220
|
+
arm.count = poles.length;
|
|
221
|
+
arm.instanceMatrix.needsUpdate = true;
|
|
222
|
+
arm.computeBoundingSphere();
|
|
223
|
+
}
|
|
224
|
+
if (brace !== null) {
|
|
225
|
+
brace.count = poles.length * 2;
|
|
226
|
+
brace.instanceMatrix.needsUpdate = true;
|
|
227
|
+
brace.computeBoundingSphere();
|
|
228
|
+
}
|
|
229
|
+
if (insulator !== null) {
|
|
230
|
+
insulator.count = poles.length * wireCount;
|
|
231
|
+
insulator.instanceMatrix.needsUpdate = true;
|
|
232
|
+
insulator.computeBoundingSphere();
|
|
233
|
+
}
|
|
234
|
+
}, [poles, height, wireCount, wireSpacing]);
|
|
235
|
+
return (_jsxs(_Fragment, { children: [_jsx("instancedMesh", { ref: trunkRef, args: [trunkGeometry, trunkMaterial, Math.max(1, poles.length)], castShadow: true, receiveShadow: true }), wireCount > 0 ? (_jsxs(_Fragment, { children: [_jsx("instancedMesh", { ref: armRef, args: [armGeometry, armMaterial, Math.max(1, poles.length)], castShadow: true, receiveShadow: true }), _jsx("instancedMesh", { ref: braceRef, args: [braceGeometry, armMaterial, Math.max(1, poles.length * 2)], castShadow: true }), _jsx("instancedMesh", { ref: insulatorRef, args: [insulatorGeometry, undefined, Math.max(1, poles.length * wireCount)], castShadow: true, children: _jsx("meshStandardMaterial", { color: "#a8a18c", roughness: 0.32, metalness: 0.12 }) })] })) : null] }));
|
|
236
|
+
}
|
|
237
|
+
function ModelPoles({ url, poles }) {
|
|
238
|
+
const gltf = useLoader(GLTFLoader, url);
|
|
239
|
+
const sources = useMemo(() => {
|
|
240
|
+
const meshes = [];
|
|
241
|
+
gltf.scene.updateWorldMatrix(true, true);
|
|
242
|
+
gltf.scene.traverse((node) => {
|
|
243
|
+
const mesh = node;
|
|
244
|
+
if (mesh.isMesh)
|
|
245
|
+
meshes.push({ geometry: mesh.geometry, material: mesh.material, local: mesh.matrixWorld.clone() });
|
|
246
|
+
});
|
|
247
|
+
// readNamedSockets is available for wire-anchor refinement; the proxy path already honors offsets.
|
|
248
|
+
void readNamedSockets(gltf.scene);
|
|
249
|
+
return meshes;
|
|
250
|
+
}, [gltf]);
|
|
251
|
+
return (_jsx(_Fragment, { children: sources.map((source, index) => (_jsx(ModelPoleSource, { source: source, poles: poles }, index))) }));
|
|
252
|
+
}
|
|
253
|
+
function ModelPoleSource({ source, poles }) {
|
|
254
|
+
const meshRef = useRef(null);
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const mesh = meshRef.current;
|
|
257
|
+
if (mesh === null)
|
|
258
|
+
return;
|
|
259
|
+
const base = new THREE.Matrix4();
|
|
260
|
+
const composed = new THREE.Matrix4();
|
|
261
|
+
const position = new THREE.Vector3();
|
|
262
|
+
const quaternion = new THREE.Quaternion();
|
|
263
|
+
const euler = new THREE.Euler();
|
|
264
|
+
const scale = new THREE.Vector3(1, 1, 1);
|
|
265
|
+
poles.forEach((pole, index) => {
|
|
266
|
+
position.set(pole.position[0], pole.position[1], pole.position[2]);
|
|
267
|
+
euler.set(0, pole.yaw, 0);
|
|
268
|
+
quaternion.setFromEuler(euler);
|
|
269
|
+
base.compose(position, quaternion, scale);
|
|
270
|
+
composed.multiplyMatrices(base, source.local);
|
|
271
|
+
mesh.setMatrixAt(index, composed);
|
|
272
|
+
});
|
|
273
|
+
mesh.count = poles.length;
|
|
274
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
275
|
+
mesh.computeBoundingSphere();
|
|
276
|
+
}, [poles, source]);
|
|
277
|
+
return _jsx("instancedMesh", { ref: meshRef, args: [source.geometry, source.material, Math.max(1, poles.length)], castShadow: true, receiveShadow: true });
|
|
278
|
+
}
|
|
279
|
+
function OnePoleLine({ object, context }) {
|
|
280
|
+
const resolved = useMemo(() => resolvePoleLine(object, parseParams(POLE_LINE_SCHEMA, object.meta), { sampleHeight: (x, z) => context.field.sampleHeight(x, z) }), [object, context.field]);
|
|
281
|
+
const modelUrl = resolved.poleAsset.length > 0 ? context.assets?.resolve(resolved.poleAsset)?.url : undefined;
|
|
282
|
+
return (_jsxs(_Fragment, { children: [modelUrl !== undefined ? (_jsx(Suspense, { fallback: _jsx(ProxyPoles, { poles: resolved.poles, height: resolved.poleHeight, wireCount: resolved.wireCount, wireSpacing: resolved.wireSpacing }), children: _jsx(ModelPoles, { url: modelUrl, poles: resolved.poles }) })) : (_jsx(ProxyPoles, { poles: resolved.poles, height: resolved.poleHeight, wireCount: resolved.wireCount, wireSpacing: resolved.wireSpacing })), _jsx(Cables, { cables: resolved.cables })] }));
|
|
283
|
+
}
|
|
284
|
+
/** Registers the built-in pole-line runtime renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
|
|
285
|
+
export function registerPoleLineRenderer() {
|
|
286
|
+
registerSceneKindRenderer(POLE_LINE_KIND, ({ objects, context }) => (_jsx(_Fragment, { children: objects.map((object) => (_jsx(OnePoleLine, { object: object, context: context }, object.id))) })));
|
|
287
|
+
}
|
|
@@ -1,19 +1,82 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo } from "react";
|
|
3
|
+
import * as THREE from "three";
|
|
3
4
|
import { resolveSoilObject } from "@jgengine/core/world/soilKind";
|
|
4
5
|
import { createSoilPatchMaterial } from "../terrain/soilPatchMaterial.js";
|
|
5
6
|
import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
/** Height offset above the sampled terrain so the draped patch never z-fights the ground. */
|
|
8
|
+
const SOIL_LIFT = 0.04;
|
|
9
|
+
/** Amplitude of the seeded micro-relief layered onto the drape, meters. */
|
|
10
|
+
const SOIL_RELIEF = 0.13;
|
|
11
|
+
/** Cheap deterministic 2D value noise (two octaves) for the patch's baked micro-relief. */
|
|
12
|
+
function reliefNoise(x, z) {
|
|
13
|
+
const n = (px, pz) => {
|
|
14
|
+
const s = Math.sin(px * 12.9898 + pz * 78.233) * 43758.5453;
|
|
15
|
+
return s - Math.floor(s);
|
|
16
|
+
};
|
|
17
|
+
const octave = (px, pz) => {
|
|
18
|
+
const ix = Math.floor(px);
|
|
19
|
+
const iz = Math.floor(pz);
|
|
20
|
+
const fx = px - ix;
|
|
21
|
+
const fz = pz - iz;
|
|
22
|
+
const ux = fx * fx * (3 - 2 * fx);
|
|
23
|
+
const uz = fz * fz * (3 - 2 * fz);
|
|
24
|
+
const a = n(ix, iz);
|
|
25
|
+
const b = n(ix + 1, iz);
|
|
26
|
+
const c = n(ix, iz + 1);
|
|
27
|
+
const d = n(ix + 1, iz + 1);
|
|
28
|
+
return a + (b - a) * ux + (c - a) * uz + (a - b - c + d) * ux * uz;
|
|
29
|
+
};
|
|
30
|
+
return octave(x * 0.7, z * 0.7) * 0.65 + octave(x * 2.3, z * 2.3) * 0.35;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* One authored soil volume → a subdivided plane draped over the live terrain (each vertex sampled
|
|
34
|
+
* from the height field) with the crack/moss shader and a noise-eroded edge fade, so the patch hugs
|
|
35
|
+
* relief and dissolves into the surrounding ground instead of reading as a hovering brown box.
|
|
36
|
+
*/
|
|
8
37
|
function OneSoil({ object, context }) {
|
|
9
38
|
const resolved = useMemo(() => resolveSoilObject(object), [object]);
|
|
10
|
-
const
|
|
39
|
+
const groundColorAt = context.groundColorAt;
|
|
40
|
+
const material = useMemo(() => resolved === null
|
|
41
|
+
? null
|
|
42
|
+
: createSoilPatchMaterial(resolved.rules, {
|
|
43
|
+
center: [resolved.center[0], resolved.center[2]],
|
|
44
|
+
halfSize: [resolved.size[0] / 2, resolved.size[1] / 2],
|
|
45
|
+
...(groundColorAt === undefined
|
|
46
|
+
? {}
|
|
47
|
+
: { groundColor: groundColorAt(resolved.center[0], resolved.center[2]) }),
|
|
48
|
+
}), [resolved, groundColorAt]);
|
|
49
|
+
const geometry = useMemo(() => {
|
|
50
|
+
if (resolved === null)
|
|
51
|
+
return null;
|
|
52
|
+
const [w, d] = resolved.size;
|
|
53
|
+
const segX = Math.min(96, Math.max(12, Math.round(w / 0.4)));
|
|
54
|
+
const segZ = Math.min(96, Math.max(12, Math.round(d / 0.4)));
|
|
55
|
+
const plane = new THREE.PlaneGeometry(w, d, segX, segZ);
|
|
56
|
+
plane.rotateX(-Math.PI / 2);
|
|
57
|
+
const positions = plane.getAttribute("position");
|
|
58
|
+
for (let i = 0; i < positions.count; i += 1) {
|
|
59
|
+
const x = positions.getX(i);
|
|
60
|
+
const z = positions.getZ(i);
|
|
61
|
+
const wx = resolved.center[0] + x;
|
|
62
|
+
const wz = resolved.center[2] + z;
|
|
63
|
+
// Seeded micro-relief so the cracked dirt catches light like packed earth, not a flat decal;
|
|
64
|
+
// the amplitude eases out toward the border so the fading edge still meets the ground flush.
|
|
65
|
+
const edgeX = 1 - Math.min(1, Math.abs(x) / (w / 2));
|
|
66
|
+
const edgeZ = 1 - Math.min(1, Math.abs(z) / (d / 2));
|
|
67
|
+
const edgeEase = Math.min(1, Math.min(edgeX, edgeZ) * 3);
|
|
68
|
+
const relief = (reliefNoise(wx, wz) - 0.5) * 2 * SOIL_RELIEF * edgeEase;
|
|
69
|
+
positions.setY(i, context.field.sampleHeight(wx, wz) + SOIL_LIFT + relief);
|
|
70
|
+
}
|
|
71
|
+
positions.needsUpdate = true;
|
|
72
|
+
plane.computeVertexNormals();
|
|
73
|
+
return plane;
|
|
74
|
+
}, [resolved, context.field]);
|
|
11
75
|
useEffect(() => () => material?.dispose(), [material]);
|
|
12
|
-
|
|
76
|
+
useEffect(() => () => geometry?.dispose(), [geometry]);
|
|
77
|
+
if (resolved === null || material === null || geometry === null)
|
|
13
78
|
return null;
|
|
14
|
-
|
|
15
|
-
const y = groundY + SOIL_PATCH_THICKNESS / 2 + 0.02;
|
|
16
|
-
return (_jsx("mesh", { position: [resolved.center[0], y, resolved.center[2]], material: material, receiveShadow: true, children: _jsx("boxGeometry", { args: [resolved.size[0], SOIL_PATCH_THICKNESS, resolved.size[1]] }) }));
|
|
79
|
+
return _jsx("mesh", { position: [resolved.center[0], 0, resolved.center[2]], geometry: geometry, material: material, receiveShadow: true });
|
|
17
80
|
}
|
|
18
81
|
/** Registers the `soil` volume runtime renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
|
|
19
82
|
export function registerSoilPatchRenderer() {
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo } from "react";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
3
|
import { resolveWaterObject } from "@jgengine/core/world/waterKind";
|
|
4
4
|
import { Ocean } from "../water/Ocean.js";
|
|
5
5
|
import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
|
|
6
6
|
/** Map the editor-authored water params onto the shell Ocean shader config. */
|
|
7
|
-
function toOceanConfig(rules, size) {
|
|
7
|
+
function toOceanConfig(rules, size, depthRange) {
|
|
8
8
|
const maxSpan = Math.max(size[0], size[1]);
|
|
9
9
|
return {
|
|
10
10
|
size: size[0],
|
|
11
11
|
depth: size[1],
|
|
12
|
-
|
|
12
|
+
// ~2 vertices per meter (bounded): the old span/2 gave a 30 m lake a 15×15 grid, which waved
|
|
13
|
+
// like a blanket instead of water.
|
|
14
|
+
resolution: Math.min(220, Math.max(48, Math.round(maxSpan * 2))),
|
|
13
15
|
amplitude: rules.waveHeight,
|
|
14
16
|
speed: rules.waveSpeed,
|
|
15
17
|
timeScale: rules.waveSpeed,
|
|
@@ -18,20 +20,42 @@ function toOceanConfig(rules, size) {
|
|
|
18
20
|
deep: rules.color,
|
|
19
21
|
shallow: rules.shallowColor,
|
|
20
22
|
opacity: rules.opacity,
|
|
21
|
-
|
|
23
|
+
// The editor's reflectivity slider stays restrained: full slider ≈ a soft sky sheen at
|
|
24
|
+
// grazing angles, never the white-out the old fresnel washed the whole sheet with.
|
|
25
|
+
fresnelStrength: rules.reflectivity * 0.9,
|
|
26
|
+
depthRange,
|
|
27
|
+
sparkle: 0.25 + rules.reflectivity * 0.3,
|
|
28
|
+
},
|
|
29
|
+
foam: {
|
|
30
|
+
coverage: Math.min(1, rules.foam / 4),
|
|
31
|
+
intensity: Math.min(1.2, rules.foam / 3),
|
|
32
|
+
shoreWidth: Math.min(1.5, rules.foam * 0.35),
|
|
22
33
|
},
|
|
23
|
-
foam: { coverage: Math.min(1, rules.foam / 4), intensity: Math.min(1.5, rules.foam / 3) },
|
|
24
34
|
};
|
|
25
35
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
36
|
+
/**
|
|
37
|
+
* One authored water volume → a bounded, animated Ocean sheet at the box top, draped over the live
|
|
38
|
+
* terrain's depth: the carved lake bed (see `lakebedFromWaterVolumes`) gives every vertex a real
|
|
39
|
+
* water-column depth, which drives shallow→deep color, shore transparency/foam, and wave damping.
|
|
40
|
+
*/
|
|
41
|
+
function OneWater({ object, context }) {
|
|
28
42
|
const resolved = useMemo(() => resolveWaterObject(object), [object]);
|
|
29
|
-
const
|
|
43
|
+
const surfaceY = resolved === null ? 0 : resolved.center[1];
|
|
44
|
+
const centerX = resolved === null ? 0 : resolved.center[0];
|
|
45
|
+
const centerZ = resolved === null ? 0 : resolved.center[2];
|
|
46
|
+
const depthAt = useCallback((x, z) => surfaceY - context.field.sampleHeight(centerX + x, centerZ + z), [context.field, surfaceY, centerX, centerZ]);
|
|
47
|
+
const config = useMemo(() => {
|
|
48
|
+
if (resolved === null)
|
|
49
|
+
return null;
|
|
50
|
+
// Full deep color roughly where the carved bed bottoms out (box depth + undercut headroom).
|
|
51
|
+
const boxDepth = Math.max(0.4, resolved.center[1] - (object.center?.y ?? resolved.center[1]) + 0.001) * 2;
|
|
52
|
+
return toOceanConfig(resolved.rules, resolved.size, Math.max(0.8, boxDepth * 0.9));
|
|
53
|
+
}, [resolved, object]);
|
|
30
54
|
if (resolved === null || config === null)
|
|
31
55
|
return null;
|
|
32
|
-
return _jsx(Ocean, { config: config, position: [
|
|
56
|
+
return _jsx(Ocean, { config: config, position: [centerX, surfaceY, centerZ], depthAt: depthAt });
|
|
33
57
|
}
|
|
34
58
|
/** Registers the `water` volume runtime renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
|
|
35
59
|
export function registerWaterRenderer() {
|
|
36
|
-
registerSceneKindRenderer("water", ({ objects }) => (_jsx(_Fragment, { children: objects.map((object) => (_jsx(OneWater, { object: object }, object.id))) })));
|
|
60
|
+
registerSceneKindRenderer("water", ({ objects, context }) => (_jsx(_Fragment, { children: objects.map((object) => (_jsx(OneWater, { object: object, context: context }, object.id))) })));
|
|
37
61
|
}
|
|
@@ -9,6 +9,8 @@ import { registerBuiltinSceneKinds } from "@jgengine/core/scene/builtinSceneKind
|
|
|
9
9
|
import { registerGrassFieldRenderer } from "./GrassFieldRenderer.js";
|
|
10
10
|
import { registerWaterRenderer } from "./WaterRenderer.js";
|
|
11
11
|
import { registerSoilPatchRenderer } from "./SoilPatchRenderer.js";
|
|
12
|
+
import { registerCityRenderer } from "./CityRenderer.js";
|
|
13
|
+
import { registerPoleLineRenderer } from "./PoleLineRenderer.js";
|
|
12
14
|
let registered = false;
|
|
13
15
|
/** Registers the engine's built-in environment scene-kind renderers. Called by `AuthoredScene`. @internal */
|
|
14
16
|
export function registerBuiltinSceneKindRenderers() {
|
|
@@ -19,4 +21,6 @@ export function registerBuiltinSceneKindRenderers() {
|
|
|
19
21
|
registerWaterRenderer();
|
|
20
22
|
registerGrassFieldRenderer();
|
|
21
23
|
registerSoilPatchRenderer();
|
|
24
|
+
registerCityRenderer();
|
|
25
|
+
registerPoleLineRenderer();
|
|
22
26
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* Instanced facade material for city massing walls: a `MeshStandardMaterial` whose fragment stage
|
|
4
|
+
* paints procedural window strips on the side faces of every instanced unit box — floor bands from
|
|
5
|
+
* the district's real `floorHeight`, window columns on a fixed pitch, and a per-cell hash that
|
|
6
|
+
* dims/lights individual windows so no two facades read identical. Ground floors (plus any buried
|
|
7
|
+
* foundation, via the `instanceGroundOffset` attribute) stay solid wall so street level reads as
|
|
8
|
+
* storefront/lobby rather than floating glass. Same `onBeforeCompile` idiom as the grass/terrain
|
|
9
|
+
* materials; instance colors keep tinting the wall exactly like the plain massing material.
|
|
10
|
+
* @internal — created by `CityRenderer` for the banded-piece InstancedMesh.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createCityWindowMaterial(options: {
|
|
13
|
+
floorHeight: number;
|
|
14
|
+
windowColor: string;
|
|
15
|
+
roughness?: number;
|
|
16
|
+
}): THREE.MeshStandardMaterial;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* Instanced facade material for city massing walls: a `MeshStandardMaterial` whose fragment stage
|
|
4
|
+
* paints procedural window strips on the side faces of every instanced unit box — floor bands from
|
|
5
|
+
* the district's real `floorHeight`, window columns on a fixed pitch, and a per-cell hash that
|
|
6
|
+
* dims/lights individual windows so no two facades read identical. Ground floors (plus any buried
|
|
7
|
+
* foundation, via the `instanceGroundOffset` attribute) stay solid wall so street level reads as
|
|
8
|
+
* storefront/lobby rather than floating glass. Same `onBeforeCompile` idiom as the grass/terrain
|
|
9
|
+
* materials; instance colors keep tinting the wall exactly like the plain massing material.
|
|
10
|
+
* @internal — created by `CityRenderer` for the banded-piece InstancedMesh.
|
|
11
|
+
*/
|
|
12
|
+
export function createCityWindowMaterial(options) {
|
|
13
|
+
const material = new THREE.MeshStandardMaterial({
|
|
14
|
+
color: "#ffffff",
|
|
15
|
+
roughness: options.roughness ?? 0.82,
|
|
16
|
+
metalness: 0.04,
|
|
17
|
+
});
|
|
18
|
+
const uniforms = {
|
|
19
|
+
uFloorHeight: { value: Math.max(2, options.floorHeight) },
|
|
20
|
+
uWindowColor: { value: new THREE.Color(options.windowColor) },
|
|
21
|
+
};
|
|
22
|
+
material.onBeforeCompile = (shader) => {
|
|
23
|
+
Object.assign(shader.uniforms, uniforms);
|
|
24
|
+
shader.vertexShader = shader.vertexShader
|
|
25
|
+
.replace("#include <common>", `
|
|
26
|
+
#include <common>
|
|
27
|
+
attribute float instanceGroundOffset;
|
|
28
|
+
varying vec3 vCityLocal;
|
|
29
|
+
varying vec3 vCityScale;
|
|
30
|
+
varying vec3 vCityNormal;
|
|
31
|
+
varying float vCityGround;
|
|
32
|
+
`)
|
|
33
|
+
.replace("#include <begin_vertex>", `
|
|
34
|
+
#include <begin_vertex>
|
|
35
|
+
vCityLocal = position;
|
|
36
|
+
vCityScale = vec3(length(instanceMatrix[0].xyz), length(instanceMatrix[1].xyz), length(instanceMatrix[2].xyz));
|
|
37
|
+
vCityNormal = normal;
|
|
38
|
+
vCityGround = instanceGroundOffset;
|
|
39
|
+
`);
|
|
40
|
+
shader.fragmentShader = shader.fragmentShader
|
|
41
|
+
.replace("#include <common>", `
|
|
42
|
+
#include <common>
|
|
43
|
+
uniform float uFloorHeight;
|
|
44
|
+
uniform vec3 uWindowColor;
|
|
45
|
+
varying vec3 vCityLocal;
|
|
46
|
+
varying vec3 vCityScale;
|
|
47
|
+
varying vec3 vCityNormal;
|
|
48
|
+
varying float vCityGround;
|
|
49
|
+
float cityHash(vec2 cell) {
|
|
50
|
+
return fract(sin(dot(cell, vec2(127.1, 311.7))) * 43758.5453);
|
|
51
|
+
}
|
|
52
|
+
`)
|
|
53
|
+
.replace("#include <color_fragment>", `
|
|
54
|
+
#include <color_fragment>
|
|
55
|
+
{
|
|
56
|
+
// Side faces only — roofs and undersides stay plain wall.
|
|
57
|
+
if (abs(vCityNormal.y) < 0.5) {
|
|
58
|
+
float along = abs(vCityNormal.x) > 0.5 ? (vCityLocal.z + 0.5) * vCityScale.z : (vCityLocal.x + 0.5) * vCityScale.x;
|
|
59
|
+
float faceWidth = abs(vCityNormal.x) > 0.5 ? vCityScale.z : vCityScale.x;
|
|
60
|
+
float up = (vCityLocal.y + 0.5) * vCityScale.y - vCityGround;
|
|
61
|
+
float pitch = 2.4;
|
|
62
|
+
float floorT = fract(up / uFloorHeight);
|
|
63
|
+
float colT = fract(along / pitch);
|
|
64
|
+
float floorIndex = floor(up / uFloorHeight);
|
|
65
|
+
float colIndex = floor(along / pitch);
|
|
66
|
+
bool window = up > uFloorHeight * 1.05 && floorT > 0.32 && floorT < 0.78 && colT > 0.2 && colT < 0.8
|
|
67
|
+
&& along > 0.7 && along < faceWidth - 0.7;
|
|
68
|
+
if (window) {
|
|
69
|
+
float cell = cityHash(vec2(floorIndex, colIndex) + vec2(vCityScale.x, vCityScale.z));
|
|
70
|
+
vec3 pane = uWindowColor * mix(0.55, 1.25, cell);
|
|
71
|
+
diffuseColor.rgb = mix(diffuseColor.rgb, pane, 0.88);
|
|
72
|
+
}
|
|
73
|
+
// A thin darker plinth line right above grade grounds the facade.
|
|
74
|
+
if (up > 0.0 && up < 0.35) diffuseColor.rgb *= 0.8;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
`);
|
|
78
|
+
};
|
|
79
|
+
material.customProgramCacheKey = () => `jgengine-city-windows`;
|
|
80
|
+
return material;
|
|
81
|
+
}
|
|
@@ -10,6 +10,8 @@ export interface SceneKindRenderContext {
|
|
|
10
10
|
field: TerrainField;
|
|
11
11
|
/** Catalog for kinds that instance real GLBs (pole assets, etc.). */
|
|
12
12
|
assets?: AssetCatalog;
|
|
13
|
+
/** Terrain surface color at a world XZ — lets kind geometry (grass roots, soil borders) blend into the ground it sits on. */
|
|
14
|
+
groundColorAt?: (x: number, z: number) => string;
|
|
13
15
|
}
|
|
14
16
|
/** A registered renderer: given every document object of its kind, returns the render tree for them. */
|
|
15
17
|
export type SceneKindRenderer = (props: {
|