@jgengine/shell 0.26.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/GamePlayerShell.d.ts +8 -0
  3. package/dist/GamePlayerShell.js +15 -1
  4. package/dist/environment/EnvironmentScene.js +51 -8
  5. package/dist/render/decalGeometry.d.ts +26 -0
  6. package/dist/render/decalGeometry.js +54 -0
  7. package/dist/render/decalTexture.d.ts +13 -0
  8. package/dist/render/decalTexture.js +114 -0
  9. package/dist/render/modelWind.d.ts +28 -0
  10. package/dist/render/modelWind.js +103 -0
  11. package/dist/render/useModelInstance.d.ts +1 -1
  12. package/dist/render/useModelInstance.js +3 -1
  13. package/dist/scatter/scatterModels.js +2 -0
  14. package/dist/scene/DecalRenderer.d.ts +2 -0
  15. package/dist/scene/DecalRenderer.js +74 -0
  16. package/dist/scene/GrassFieldRenderer.js +3 -2
  17. package/dist/scene/builtinSceneKindRenderers.js +2 -0
  18. package/dist/terrain/GrassCover.d.ts +21 -0
  19. package/dist/terrain/GrassCover.js +84 -0
  20. package/dist/terrain/grassCoverGeometry.d.ts +40 -0
  21. package/dist/terrain/grassCoverGeometry.js +112 -0
  22. package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
  23. package/dist/terrain/terrainDetailMaterial.js +54 -1
  24. package/dist/terrain/terrainSurfaces.d.ts +6 -0
  25. package/dist/terrain/terrainSurfaces.js +10 -0
  26. package/dist/world/WorldScene.js +2 -1
  27. package/package.json +4 -4
  28. package/skills/jgengine/api.md +2 -2
  29. package/skills/jgengine-ui/api.md +32 -1
  30. package/skills/jgengine-ui/capabilities.md +4 -0
  31. package/skills/jgengine-world/SKILL.md +4 -0
  32. package/skills/jgengine-world/api.md +115 -5
  33. package/skills/jgengine-world/capabilities.md +20 -0
  34. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
package/CHANGELOG.md CHANGED
@@ -26,6 +26,50 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.27.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.27.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.24.0`; `@jgengine/github` is `0.14.0`.
34
+
35
+ ### Added
36
+
37
+ - `rollLoot` (`@jgengine/core/loot/lootGen`) rolls a named item from a caller `LootCatalog`: bases with mount slots, parts, affix pools, weighted rarity tiers, manufacturer personalities and a `levelScale` curve. Stats are `base × rarity.statScale × levelScale`, then part stats/multipliers, maker `bias`/`scale`, then additive and multiplicative affixes; additive affix rolls scale with level, multiplicative ones do not.
38
+ - `createLootRoller` wraps a validated catalog with roll counts per rarity, `snapshot()`/`restore()`/`reset()` and an optional devtools probe; `assertLootCatalog` rejects duplicate ids, unresolvable pools, empty item slots and a non-ascending level curve at construction.
39
+ - `validateLootItem` returns coded issues (`unknown-base`, `manufacturer-base`, `missing-slot-part`, `part-category`, `unknown-affix`, `empty-name`, …) and `compareLootItems` returns sorted per-stat deltas plus a weighted score with caller-named `lowerIsBetter` stats.
40
+ - `lootWeaponDefinition` (`@jgengine/core/loot/lootWeapon`) projects a rolled item onto a `WeaponDefinition` through a caller stat map, so a generated gun runs in `createWeaponSystem` after `assertWeaponDefinition`.
41
+ - `defineXpCurve` (`@jgengine/core/gameplay`) resolves one authored growth rule (`flat`, `linear`, `power`, `geometric`, `table`) plus a `maxLevel` cap into per-level thresholds, lifetime totals, a plain-words rule string and a `leveling` track.
42
+ - `createSkillTree` spends and refunds points over authored nodes with `cost`, `requires`, `excludes`, passives, actives and multi-node `synergies`; `resolved()` returns the stat modifiers and actions, and state snapshots/restores as `{ points, ranks }`.
43
+ - `gearScore` / `compareGearSets` score a whole loadout from stat weights, level, rarity and slot coverage.
44
+ - `lootStatContributions` and `scoreLootStats` expose the weight policy `compareLootItems` already used.
45
+ - `createEquipment` (`@jgengine/core/inventory/equipment`) wears gear by slot: slot defs declare accepted kinds and the slots they block, item defs declare the slots an item may occupy, a filled slot swaps and hands back the replaced item, and a two-handed item and the slot it blocks cannot both be filled. `state()` / `restore()` round-trip the worn set; a devtools probe reports filled slots.
46
+ - `characterLoad` and `canCarry` (`@jgengine/core/inventory/carryLoad`) weigh carried inventories plus worn gear against a capacity and hand back the shared encumbrance bands (tier, `moveMultiplier`, sprint/jump gates, `remaining`), so a stash left out of `carried` adds no weight.
47
+ - `fitShapedItem` (`@jgengine/core/inventory/shapedGrid`) places a shaped item at its first fit or names why not: `too-large` (with both sizes), `no-space`, `budget-exceeded`.
48
+ - `InventorySet.snapshot()` / `restore()` / `reset()` (`@jgengine/core/inventory/inventoryModel`) save and reload every declared inventory as detached plain data, so a bag and a stash never share live slots.
49
+ - `splitStack` takes an optional stack `limit`, rejecting `over-stack` instead of merging past the max stack.
50
+ - `createLaneGraph` and `laneGraphFromDocument` (`@jgengine/core/world`) read an authored lane graph out of the scene document: `traffic_lane` paths are one directed span each, `road` paths (the street bake) become a lane per direction split where streets cross, `traffic_intersection` markers carry signal phases and right-of-way in `meta`, and `traffic_parking` markers place kerbside spots.
51
+ - `createTraffic` (`@jgengine/core/world`) runs cars on that graph: IDM car following and braking, one car at a time through each intersection box behind a signal phase or first-come right-of-way, lane changes across authored neighbours, parking that reserves a spot, deterministic injected variety, `snapshot`/`restore`, and a devtools probe.
52
+ - `Traffic.setDemand(count)` is the fleet target; the time-of-day curve stays the caller's policy.
53
+ - `applyVehicleParts` (`@jgengine/core/world`) composes data-driven tuning parts onto a `KinematicVehicleTuning` — stat multipliers/addends applied in install order plus an optional tire grip curve — and returns the composed tuning, the installed ids, and a `VehicleFeel` (top speed, drive, brakes, turn rate, handbrake grip, and the grip curve's peak/slide). `withPart` swaps a part within its slot and `feelOf` reads the numbers out of any tuning.
54
+ - `createVehicleDamage` (`@jgengine/core/world`) is fixed-zone crash damage wired to handling and visuals: impacts accumulate into discrete stages, each stage costs grip/drive/brakes/top speed/drag and pulls the steering (`effect`), and each zone shows one bounded dent (`deformation`) that never exceeds its authored `deform`. Parts can detach at a set stage, a total impulse can disable the vehicle, and `snapshot`/`restore` resume mid-crash. `damagedTuning` and `damagedAxis` fold the effect into a tuning or an input, and `modifiers()` carries it as per-tick overrides.
55
+ - `KinematicVehicleTuning.weightTransfer` (`@jgengine/core/world`) adds load-transfer grip: longitudinal transfer under braking/traction and lateral transfer under cornering cut the tire grip curve (tire load sensitivity), so braking into a corner holds less slide than coasting through it. Each tick reports `weightTransfer` and `loadGrip`; omitting the block keeps the previous flat-grip model exactly.
56
+ - `KinematicVehicleModifiers.gripScale` scales the grip curve for one tick, mirroring `VehicleDynamicsModifiers.gripScale` so a damage layer drives either sim.
57
+ - `decal` scene kind (`@jgengine/core/world/decalKind`) and its renderer (`@jgengine/shell/scene/DecalRenderer`): dirt, cracks, paint and blood decals authored as placed markers, draped on the live terrain or standing on an authored tilt, with a deterministic procedural texture per style+seed.
58
+ - `TerrainMaterialLayer.material` (`@jgengine/core/world/terraform`): a material library name whose scanned color map paints that terrain layer. The runtime samples up to four layer maps at each layer's `tiling` and blends them through the authored mask weights (`createTerrainDetailMaterial`'s `layerMaps`), so painted terrain blends between authored layers instead of tiling one flat map.
59
+ - `grass_field` volumes now paint a ground-cover mat under their blades (`groundCover` rule, `@jgengine/shell/terrain/GrassCover`), so an authored patch reads as turf rather than bare ground with stubble. `GrassEnvironmentConfig.groundCover` does the same for `grass()` patches.
60
+ - `ModelConfig.wind` (`@jgengine/core/game/playableGame`) and `applyModelWind` (`@jgengine/shell/render/modelWind`): per-model foliage sway with height-ramped travel, shared gust clock and per-model strength/speed/direction.
61
+
62
+ ### Fixed
63
+
64
+ - The player shell re-renders only for the ctx slices it reads — the possessed entity and the touch control mode — instead of on every `ctx` write. Subscribing the shell root to the raw ctx version reconciled the canvas, the whole actor tree and the HUD on each per-frame entity pose write, which is what `subscribeMembership` and the markers' imperative `useFrame` pose reads exist to avoid (#625). Scrap Signal: 86.6 ms → 16.7 ms per frame at the same 621 draws; Harbor Heat: 102.8 ms → 46.2 ms at the same ~7.2k draws.
65
+ - The `ocean()` environment feature passes the live ground to its shader, so the sea shades shallow→deep and its shoreline foam band lands where the bed meets the surface.
66
+
67
+ ### Numbers
68
+
69
+ - Test catalog (60/25/10/4/1 rarity weights) over 10,000 drops at level 20, fixed seed: common 59.41% (target 60%), uncommon 25.66% (25%), rare 9.58% (10%), epic 4.20% (4%), legendary 1.15% (1%); every tier inside its 4σ binomial bound. All 10,000 items validated with zero issues and composed names (e.g. `Voss Rare Long Keen Lightframe of Reach`, `Tallow Long Lightframe`, `Voss Snub Lightframe of Steadying`).
70
+ - Level curve `[[1,1],[15,1.6],[30,2.4],[60,4]]`: a level-60 roll's base and additive affix values are 4× the same seed at level 1; maker personalities move the same roll (`Voss` +power/−cadence, `Tallow` the reverse).
71
+ - A rolled epic projects to a hitscan `WeaponDefinition` (rate, damage, integer magazine capacity, spread, range), passes `assertWeaponDefinition`, and its first `tick` fires one shot and one impact through `createDamageRuntime` with the rolled capacity spent.
72
+
29
73
  ## 0.26.0
30
74
 
31
75
  ### Migrate
@@ -1,6 +1,14 @@
1
1
  import { type GameContext } from "@jgengine/core/runtime/gameContext";
2
2
  import type { ShellMultiplayer } from "./multiplayer.js";
3
3
  import type { PlayableGame } from "./registry.js";
4
+ /**
5
+ * Re-render the shell root only for the ctx slices it reads: the entity the local player possesses,
6
+ * and the active touch-control mode. Subscribing to the raw ctx version instead reconciled the
7
+ * canvas, the whole actor tree and the HUD on every per-frame pose and store write — the churn
8
+ * `subscribeMembership` and imperative `useFrame` pose reads exist to keep out of React (#625).
9
+ * @internal
10
+ */
11
+ export declare function useShellRootResubscribe(ctx: GameContext | null, userId: string): void;
4
12
  export { applyMotionImpulses } from "@jgengine/core/runtime/motionIntents";
5
13
  export { nearbyObstacles } from "@jgengine/core/movement/movementModel";
6
14
  export { resolvePhysicsTuning } from "@jgengine/core/movement/playerMovement";
@@ -47,6 +47,19 @@ const DEV_USER_ID = "dev-player";
47
47
  const DEBUG_PARAM = "debug";
48
48
  const NO_SUBSCRIBE = () => () => undefined;
49
49
  const ZERO_VERSION = () => 0;
50
+ function shellRootSnapshot(ctx, userId) {
51
+ return `${ctx.player.possession.active(userId)}|${activeTouchControlsMode(ctx) ?? ""}`;
52
+ }
53
+ /**
54
+ * Re-render the shell root only for the ctx slices it reads: the entity the local player possesses,
55
+ * and the active touch-control mode. Subscribing to the raw ctx version instead reconciled the
56
+ * canvas, the whole actor tree and the HUD on every per-frame pose and store write — the churn
57
+ * `subscribeMembership` and imperative `useFrame` pose reads exist to keep out of React (#625).
58
+ * @internal
59
+ */
60
+ export function useShellRootResubscribe(ctx, userId) {
61
+ useSyncExternalStore(ctx?.subscribe ?? NO_SUBSCRIBE, () => (ctx === null ? "" : shellRootSnapshot(ctx, userId)), () => "");
62
+ }
50
63
  export { applyMotionImpulses } from "@jgengine/core/runtime/motionIntents";
51
64
  export { nearbyObstacles } from "@jgengine/core/movement/movementModel";
52
65
  export { resolvePhysicsTuning } from "@jgengine/core/movement/playerMovement";
@@ -330,7 +343,8 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
330
343
  useEffect(() => {
331
344
  wrapperRef.current?.focus();
332
345
  }, [ctx]);
333
- useSyncExternalStore(ctx?.subscribe ?? (() => () => undefined), ctx?.version ?? (() => 0), ctx?.version ?? (() => 0));
346
+ // The shell root re-renders only for the ctx slices it reads — see {@link useShellRootResubscribe}.
347
+ useShellRootResubscribe(ctx, userId);
334
348
  if (ctx === null)
335
349
  return _jsx("div", { className: "h-full w-full bg-neutral-950" });
336
350
  if (join.status !== "joined")
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef } from "react";
3
3
  import { useFrame } from "@react-three/fiber";
4
4
  import { PresentationAssetBoundary } from "../presentationRecovery.js";
5
5
  import { PresentationRecoveredDraw, useRecoverableTexture } from "../presentationRecovery3d.js";
@@ -18,14 +18,16 @@ import { buildAuthoredRoadNetwork, RoadRibbons } from "./RoadRibbons.js";
18
18
  import { terrainGroundColorSampler } from "./terrainGroundColor.js";
19
19
  import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
20
20
  import { GrassField } from "../terrain/GrassField.js";
21
+ import { GrassCover } from "../terrain/GrassCover.js";
21
22
  import { CarvedTerrain } from "../terrain/CarvedTerrain.js";
22
23
  import { createTerrainDetailMaterial } from "../terrain/terrainDetailMaterial.js";
24
+ import { MAX_TERRAIN_LAYER_MAPS } from "../terrain/terrainDetailMaterial.js";
23
25
  import { useTerrainQuality } from "../terrain/terrainQuality.js";
24
26
  import { createTerrainPaintTextures, packTerrainPaintWeights } from "../terrain/terrainSurfaces.js";
25
27
  import { Ocean } from "../water/Ocean.js";
26
28
  import { RainField } from "../weather/RainField.js";
27
29
  import { SnowField } from "../weather/SnowField.js";
28
- function TexturedTerrainGround({ detail, layout, }) {
30
+ function TexturedTerrainGround({ detail, layout, layerMaps, }) {
29
31
  const maps = detail.material.maps;
30
32
  const textures = useRecoverableTexture({
31
33
  color: maps.color,
@@ -42,9 +44,44 @@ function TexturedTerrainGround({ detail, layout, }) {
42
44
  }
43
45
  }, [textures]);
44
46
  const quality = useTerrainQuality();
45
- const material = useDisposable(() => createTerrainDetailMaterial(detail, textures, { paint: layout.paint, quality }).material, [detail, textures, layout.paint, quality]);
47
+ const material = useDisposable(() => createTerrainDetailMaterial(detail, textures, { paint: layout.paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [detail, textures, layout.paint, quality, layerMaps]);
46
48
  return (_jsxs(_Fragment, { children: [_jsx(PresentationRecoveredDraw, {}), _jsx(CarvedTerrain, { field: layout.field, size: layout.size, segments: layout.segments, colors: layout.colors, heightRange: layout.heightRange, paletteAt: layout.paletteAt, center: layout.center, roughness: 0.94, surfaceMaterial: material })] }));
47
49
  }
50
+ /** 1×1 white PNG: binds a valid sampler for a painted layer that has no scanned map. */
51
+ const NO_LAYER_MAP = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
52
+ /**
53
+ * Scanned color maps for up to {@link MAX_TERRAIN_LAYER_MAPS} painted terrain layers, loaded through
54
+ * the shared recoverable texture path and tiled at each layer's own authored metres. The URL is
55
+ * authored data (`TerrainMaterialLayer.materialUrl`), resolved by the editor from a library name, so
56
+ * the renderer never reaches into the asset pipeline.
57
+ */
58
+ function useTerrainLayerMaps(paint) {
59
+ const urls = useMemo(() => Array.from({ length: MAX_TERRAIN_LAYER_MAPS }, (_, index) => {
60
+ const url = paint?.slots[index + 1]?.materialUrl;
61
+ return url === undefined || url.length === 0 ? NO_LAYER_MAP : url;
62
+ }), [paint]);
63
+ const textures = useRecoverableTexture({ layer1: urls[0], layer2: urls[1], layer3: urls[2], layer4: urls[3] });
64
+ useEffect(() => {
65
+ for (const texture of Object.values(textures)) {
66
+ texture.colorSpace = THREE.SRGBColorSpace;
67
+ texture.wrapS = THREE.RepeatWrapping;
68
+ texture.wrapT = THREE.RepeatWrapping;
69
+ texture.needsUpdate = true;
70
+ }
71
+ }, [textures]);
72
+ const slots = paint?.slots;
73
+ return useMemo(() => {
74
+ if (slots === undefined)
75
+ return undefined;
76
+ return Array.from({ length: MAX_TERRAIN_LAYER_MAPS }, (_, index) => {
77
+ const slot = slots[index + 1];
78
+ if (slot?.materialUrl === undefined)
79
+ return undefined;
80
+ const texture = textures[`layer${index + 1}`];
81
+ return texture === undefined ? undefined : { color: texture, tileMetres: slot.tileMetres };
82
+ });
83
+ }, [slots, textures]);
84
+ }
48
85
  function TerrainGround({ terrain, field, center, sculpt, }) {
49
86
  const detailEnabled = terrain.detail !== false;
50
87
  const paint = useMemo(() => {
@@ -79,12 +116,13 @@ function TerrainGround({ terrain, field, center, sculpt, }) {
79
116
  }, [terrain.baseHeight, terrain.height]);
80
117
  const resolvedDetail = useMemo(() => (terrain.detail === false ? undefined : resolveTerrainDetail(terrain.detail, terrain.waterLevel)), [terrain.detail, terrain.waterLevel]);
81
118
  const quality = useTerrainQuality();
119
+ const layerMaps = useTerrainLayerMaps(paint);
82
120
  const proceduralMaterial = useMemo(() => resolvedDetail === undefined || resolvedDetail.material !== undefined
83
121
  ? undefined
84
- : createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality }).material, [resolvedDetail, paint, quality]);
122
+ : createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality, ...(layerMaps === undefined ? {} : { layerMaps }) }).material, [resolvedDetail, paint, quality, layerMaps]);
85
123
  useEffect(() => () => proceduralMaterial?.dispose(), [proceduralMaterial]);
86
124
  if (resolvedDetail?.material !== undefined) {
87
- return (_jsx(PresentationAssetBoundary, { children: _jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center } }) }));
125
+ return (_jsx(PresentationAssetBoundary, { children: _jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center }, ...(layerMaps === undefined ? {} : { layerMaps }) }) }));
88
126
  }
89
127
  return (_jsx(CarvedTerrain, { field: field, size: size, segments: terrain.segments, colors: colors, heightRange: heightRange, paletteAt: paletteAt, paintColorAt: paintColorAt, center: center, roughness: 0.94, surfaceMaterial: proceduralMaterial }));
90
128
  }
@@ -100,7 +138,7 @@ function Vegetation({ grass, field, groundColor, }) {
100
138
  const wind = useMemo(() => ({ strength: grass.windStrength }), [grass.windStrength]);
101
139
  const colorBase = grass.colors[0];
102
140
  const colorTip = grass.colors[grass.colors.length - 1];
103
- return (_jsx("group", { position: [cx, 0, cz], children: _jsx(GrassField, { area: area, count: Math.max(1500, Math.min(250000, Math.ceil(grass.density * grass.area.w * grass.area.d))), density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, bladeBend: grass.bladeBend, tuftRadius: grass.tuftRadius, colorVariation: grass.colorVariation, normalLift: grass.normalLift, heightAt: heightAt, colorBase: colorBase, colorTip: colorTip, colorGround: groundColor, wind: wind, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), frustumCulled: true }) }));
141
+ return (_jsxs("group", { position: [cx, 0, cz], children: [_jsx(GrassCover, { area: area, seed: typeof grass.seed === "number" ? grass.seed : 1, colorBase: colorBase ?? "#3f7d2d", colorTip: colorTip ?? "#66a83f", strength: grass.groundCover ?? 0.9, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), heightAt: heightAt, coverageAt: () => 1 }), _jsx(GrassField, { area: area, count: Math.max(1500, Math.min(250000, Math.ceil(grass.density * grass.area.w * grass.area.d))), density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, bladeBend: grass.bladeBend, tuftRadius: grass.tuftRadius, colorVariation: grass.colorVariation, normalLift: grass.normalLift, heightAt: heightAt, colorBase: colorBase, colorTip: colorTip, colorGround: groundColor, wind: wind, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), frustumCulled: true })] }));
104
142
  }
105
143
  /** Terrain color under a grass patch center — so blade roots blend into the ground they stand on. */
106
144
  function vegetationGroundColor(terrain, field, grass, sculpt) {
@@ -142,19 +180,24 @@ function DynamicWater({ ocean }) {
142
180
  const groupRef = useRef(null);
143
181
  const [x, z] = ocean.position ?? [0, 0];
144
182
  const config = useOceanConfig(ocean);
183
+ const depthAt = useCallback((lx, lz) => ocean.levelAt(ctx.time.now()) - ctx.world.ground.sampleHeight(x + lx, z + lz), [ctx.time.now, ctx.world.ground, ocean.levelAt, x, z]);
145
184
  useFrame(() => {
146
185
  if (groupRef.current !== null)
147
186
  groupRef.current.position.y = ocean.levelAt(ctx.time.now());
148
187
  });
149
- return (_jsx("group", { ref: groupRef, position: [0, ocean.level, 0], children: _jsx(Ocean, { position: [x, 0, z], config: config }) }));
188
+ return (_jsx("group", { ref: groupRef, position: [0, ocean.level, 0], children: _jsx(Ocean, { position: [x, 0, z], config: config, depthAt: depthAt }) }));
150
189
  }
151
190
  function Water({ ocean }) {
191
+ const ctx = useGameContext();
152
192
  const [x, z] = ocean.position ?? [0, 0];
153
193
  const config = useOceanConfig(ocean);
194
+ // The ocean descriptor has no depth field: sample the live ground so the sheet shades
195
+ // shallow→deep and the shoreline foam band lands where the bed meets the surface.
196
+ const depthAt = useCallback((lx, lz) => ocean.level - ctx.world.ground.sampleHeight(x + lx, z + lz), [ctx.world.ground, ocean.level, x, z]);
154
197
  if (ocean.levelAt !== undefined) {
155
198
  return _jsx(DynamicWater, { ocean: ocean });
156
199
  }
157
- return _jsx(Ocean, { position: [x, ocean.level, z], config: config });
200
+ return _jsx(Ocean, { position: [x, ocean.level, z], config: config, depthAt: depthAt });
158
201
  }
159
202
  function Structures({ structures, field }) {
160
203
  const placements = useMemo(() => resolveStructureBuildings(structures).map((building) => ({
@@ -0,0 +1,26 @@
1
+ import type { TerrainHeightSampler } from "../terrain/terrainMath.js";
2
+ /** Inputs for {@link createDecalDrapeGeometry}. */
3
+ export interface DecalDrapeOptions {
4
+ /** Decal edge length in metres. */
5
+ size: number;
6
+ rotationY: number;
7
+ heightAt: TerrainHeightSampler;
8
+ /** Height above the sampled ground, metres. */
9
+ lift: number;
10
+ /** Grid resolution per axis; defaults to 12 (bounded, decals are small). */
11
+ segments?: number;
12
+ }
13
+ /** Baked vertex/index buffers for one draped ground decal. */
14
+ export interface DecalDrapeGeometry {
15
+ positions: Float32Array;
16
+ normals: Float32Array;
17
+ uvs: Float32Array;
18
+ indices: Uint32Array;
19
+ }
20
+ /**
21
+ * A ground decal's draped quad: an `size`² patch rotated by `rotationY`, every vertex lifted onto
22
+ * the live terrain so a stain follows a curb or a camber instead of floating over it or sinking
23
+ * through it. Bounded to one small grid — decals are metres, not patches.
24
+ * @internal
25
+ */
26
+ export declare function createDecalDrapeGeometry(options: DecalDrapeOptions): DecalDrapeGeometry;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A ground decal's draped quad: an `size`² patch rotated by `rotationY`, every vertex lifted onto
3
+ * the live terrain so a stain follows a curb or a camber instead of floating over it or sinking
4
+ * through it. Bounded to one small grid — decals are metres, not patches.
5
+ * @internal
6
+ */
7
+ export function createDecalDrapeGeometry(options) {
8
+ const segments = Math.max(2, Math.min(24, Math.floor(options.segments ?? 12)));
9
+ const half = options.size / 2;
10
+ const cos = Math.cos(options.rotationY);
11
+ const sin = Math.sin(options.rotationY);
12
+ const cols = segments + 1;
13
+ const positions = new Float32Array(cols * cols * 3);
14
+ const normals = new Float32Array(cols * cols * 3);
15
+ const uvs = new Float32Array(cols * cols * 2);
16
+ const eps = Math.max(0.05, options.size / segments / 2);
17
+ for (let iz = 0; iz < cols; iz += 1) {
18
+ for (let ix = 0; ix < cols; ix += 1) {
19
+ const lx = -half + (ix / segments) * options.size;
20
+ const lz = -half + (iz / segments) * options.size;
21
+ const x = lx * cos + lz * sin;
22
+ const z = -lx * sin + lz * cos;
23
+ const index = iz * cols + ix;
24
+ positions[index * 3] = x;
25
+ positions[index * 3 + 1] = options.heightAt(x, z) + options.lift;
26
+ positions[index * 3 + 2] = z;
27
+ const hx = options.heightAt(x + eps, z) - options.heightAt(x - eps, z);
28
+ const hz = options.heightAt(x, z + eps) - options.heightAt(x, z - eps);
29
+ const length = Math.hypot(hx, 2 * eps, hz) || 1;
30
+ normals[index * 3] = -hx / length;
31
+ normals[index * 3 + 1] = (2 * eps) / length;
32
+ normals[index * 3 + 2] = -hz / length;
33
+ uvs[index * 2] = ix / segments;
34
+ uvs[index * 2 + 1] = 1 - iz / segments;
35
+ }
36
+ }
37
+ const indices = new Uint32Array(segments * segments * 6);
38
+ let cursor = 0;
39
+ for (let iz = 0; iz < segments; iz += 1) {
40
+ for (let ix = 0; ix < segments; ix += 1) {
41
+ const a = iz * cols + ix;
42
+ const b = a + 1;
43
+ const c = a + cols;
44
+ const d = c + 1;
45
+ indices[cursor++] = a;
46
+ indices[cursor++] = c;
47
+ indices[cursor++] = b;
48
+ indices[cursor++] = b;
49
+ indices[cursor++] = c;
50
+ indices[cursor++] = d;
51
+ }
52
+ }
53
+ return { positions, normals, uvs, indices };
54
+ }
@@ -0,0 +1,13 @@
1
+ import type { DecalStyle } from "@jgengine/core/world/decalKind";
2
+ /** Row-major RGBA bytes plus their edge length, ready for a `THREE.DataTexture`. */
3
+ export interface DecalTextureData {
4
+ size: number;
5
+ data: Uint8Array;
6
+ }
7
+ /**
8
+ * The deterministic procedural texture behind one decal style. Same style+seed always bakes the
9
+ * same bytes; the renderer uploads it once as a `DataTexture`, so no scan is pulled and no game
10
+ * ships stain art it cannot inspect. Alpha is the coverage; colour is the stain itself.
11
+ * @internal
12
+ */
13
+ export declare function createDecalTexture(style: DecalStyle, seed: string, size?: number): DecalTextureData;
@@ -0,0 +1,114 @@
1
+ function hash(x, y, seed) {
2
+ let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(seed | 0, 2246822519);
3
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
4
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
5
+ }
6
+ function smooth(t) {
7
+ return t * t * (3 - 2 * t);
8
+ }
9
+ function noise(x, y, seed) {
10
+ const xi = Math.floor(x);
11
+ const yi = Math.floor(y);
12
+ const tx = smooth(x - xi);
13
+ const ty = smooth(y - yi);
14
+ const a = hash(xi, yi, seed);
15
+ const b = hash(xi + 1, yi, seed);
16
+ const c = hash(xi, yi + 1, seed);
17
+ const d = hash(xi + 1, yi + 1, seed);
18
+ return (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty;
19
+ }
20
+ function fbm(x, y, seed) {
21
+ return noise(x, y, seed) * 0.62 + noise(x * 2.1 + 7.3, y * 2.1 - 3.1, seed + 11) * 0.26 + noise(x * 4.3 - 1.7, y * 4.3 + 5.9, seed + 23) * 0.12;
22
+ }
23
+ function seedOf(text) {
24
+ let h = 2166136261;
25
+ for (let i = 0; i < text.length; i += 1) {
26
+ h ^= text.charCodeAt(i);
27
+ h = Math.imul(h, 16777619);
28
+ }
29
+ return h >>> 0;
30
+ }
31
+ function smoothstep(edge0, edge1, x) {
32
+ const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0 || 1e-6)));
33
+ return t * t * (3 - 2 * t);
34
+ }
35
+ /**
36
+ * The deterministic procedural texture behind one decal style. Same style+seed always bakes the
37
+ * same bytes; the renderer uploads it once as a `DataTexture`, so no scan is pulled and no game
38
+ * ships stain art it cannot inspect. Alpha is the coverage; colour is the stain itself.
39
+ * @internal
40
+ */
41
+ export function createDecalTexture(style, seed, size = 128) {
42
+ const seedNumber = seedOf(seed.length > 0 ? seed : style);
43
+ const data = new Uint8Array(size * size * 4);
44
+ for (let y = 0; y < size; y += 1) {
45
+ for (let x = 0; x < size; x += 1) {
46
+ const u = x / size - 0.5;
47
+ const v = y / size - 0.5;
48
+ const r = Math.hypot(u, v) * 2;
49
+ const n = fbm(u * 5 + 13.1, v * 5 - 7.7, seedNumber);
50
+ let alpha = 0;
51
+ let red = 0;
52
+ let green = 0;
53
+ let blue = 0;
54
+ if (style === "dirt") {
55
+ const grain = fbm(u * 17 + 3.3, v * 17 + 9.1, seedNumber + 5);
56
+ alpha = smoothstep(0.5, 0.62, n) * (1 - smoothstep(0.55, 1, r)) * (0.55 + grain * 0.45);
57
+ const shade = 0.75 + grain * 0.5;
58
+ red = 0.36 * shade;
59
+ green = 0.29 * shade;
60
+ blue = 0.19 * shade;
61
+ }
62
+ else if (style === "blood") {
63
+ const splatter = fbm(u * 7 - 2.2, v * 7 + 4.4, seedNumber);
64
+ const main = smoothstep(0.46, 0.58, splatter) * (1 - smoothstep(0.6, 1, r));
65
+ // Satellite droplets: 26 seeded points, each a soft dark ring.
66
+ let drops = 0;
67
+ for (let i = 0; i < 26; i += 1) {
68
+ const a = hash(i, 3, seedNumber) * Math.PI * 2;
69
+ const d = 0.08 + hash(i, 7, seedNumber) * 0.42;
70
+ const radius = 0.012 + hash(i, 11, seedNumber) * 0.03;
71
+ const dist = Math.hypot(u - Math.cos(a) * d, v - Math.sin(a) * d);
72
+ drops = Math.max(drops, 1 - smoothstep(radius * 0.6, radius, dist));
73
+ }
74
+ alpha = Math.max(main, drops);
75
+ const shade = 0.8 + splatter * 0.35;
76
+ red = 0.46 * shade;
77
+ green = 0.055 * shade;
78
+ blue = 0.045 * shade;
79
+ }
80
+ else if (style === "paint") {
81
+ const body = smoothstep(0.44, 0.55, n) * (1 - smoothstep(0.7, 1, r));
82
+ const streak = smoothstep(0.38, 0.52, fbm(u * 22 + 1.7, v * 5 + 8.3, seedNumber + 3)) * smoothstep(0.2, 0.9, v + 0.5) * 0.5;
83
+ alpha = Math.max(body, streak * (1 - smoothstep(0.5, 0.9, r)));
84
+ const shade = 0.86 + n * 0.22;
85
+ red = 0.84 * shade;
86
+ green = 0.8 * shade;
87
+ blue = 0.72 * shade;
88
+ }
89
+ else {
90
+ // Cracks: six warped radial lines plus two branches, faded toward the rim.
91
+ let line = 0;
92
+ for (let i = 0; i < 8; i += 1) {
93
+ const base = (i / 8) * Math.PI * 2 + hash(i, 1, seedNumber) * 0.7;
94
+ const wobble = (fbm(u * 9 + i * 3.1, v * 9 - i * 2.3, seedNumber + i) - 0.5) * 0.5;
95
+ const angle = Math.atan2(v, u);
96
+ const delta = Math.abs(Math.atan2(Math.sin(angle - base - wobble), Math.cos(angle - base - wobble)));
97
+ const width = 0.022 + hash(i, 5, seedNumber) * 0.02;
98
+ line = Math.max(line, (1 - smoothstep(0, width, delta * Math.max(0.05, r))) * (1 - smoothstep(0.45, 1, r)));
99
+ }
100
+ const grit = fbm(u * 30 + 4.1, v * 30 - 2.8, seedNumber + 9) * 0.35;
101
+ alpha = Math.max(line, grit * smoothstep(0.35, 0.7, n) * (1 - smoothstep(0.6, 1, r)));
102
+ red = 0.13;
103
+ green = 0.12;
104
+ blue = 0.11;
105
+ }
106
+ const index = (y * size + x) * 4;
107
+ data[index] = Math.round(Math.min(1, red) * 255);
108
+ data[index + 1] = Math.round(Math.min(1, green) * 255);
109
+ data[index + 2] = Math.round(Math.min(1, blue) * 255);
110
+ data[index + 3] = Math.round(Math.min(1, alpha) * 255);
111
+ }
112
+ }
113
+ return { size, data };
114
+ }
@@ -0,0 +1,28 @@
1
+ import * as THREE from "three";
2
+ import type { ModelWindConfig } from "@jgengine/core/game/playableGame";
3
+ /** Shared gust clock every foliage material reads; advanced once per frame by `WindClock`. */
4
+ export declare const windClock: {
5
+ value: number;
6
+ };
7
+ /** Shared gust direction (XZ); a model may override it with its own authored direction. */
8
+ export declare const windDirection: THREE.Vector2;
9
+ /** Resolved sway settings baked into one material. */
10
+ export interface ResolvedModelWind {
11
+ strength: number;
12
+ speed: number;
13
+ height: number;
14
+ direction: THREE.Vector2;
15
+ }
16
+ /** Fully-defaulted sway settings for a model. @internal */
17
+ export declare function resolveModelWind(wind: ModelWindConfig | false | undefined, fallbackHeight: number): ResolvedModelWind | null;
18
+ /**
19
+ * Compiles height-ramped wind sway into a standard material's vertex shader. Idempotent per
20
+ * material; the clock and direction are shared module uniforms, so the whole scene advances with
21
+ * one write per frame and no per-material bookkeeping.
22
+ * @capability model-wind sway a foliage model in the shared gust field
23
+ */
24
+ export declare function applyModelWind(material: THREE.MeshStandardMaterial, wind: ResolvedModelWind): THREE.MeshStandardMaterial;
25
+ /** Applies wind to every standard material under a cloned model. @internal */
26
+ export declare function applyModelWindToScene(root: THREE.Object3D, wind: ModelWindConfig | false | undefined, fallbackHeight?: number): void;
27
+ /** Advances the shared gust clock once per frame; mount one per 3D world. */
28
+ export declare function WindClock(): null;
@@ -0,0 +1,103 @@
1
+ import * as THREE from "three";
2
+ import { useFrame } from "@react-three/fiber";
3
+ /** Shared gust clock every foliage material reads; advanced once per frame by `WindClock`. */
4
+ export const windClock = { value: 0 };
5
+ /** Shared gust direction (XZ); a model may override it with its own authored direction. */
6
+ export const windDirection = new THREE.Vector2(1, 0.35).normalize();
7
+ const WIND_KEY = "jgWind";
8
+ /** Fully-defaulted sway settings for a model. @internal */
9
+ export function resolveModelWind(wind, fallbackHeight) {
10
+ if (wind === false || wind === undefined)
11
+ return null;
12
+ const strength = wind.strength ?? 0.35;
13
+ if (!Number.isFinite(strength) || strength < 0)
14
+ throw new Error("Model wind strength must be finite and non-negative");
15
+ const speed = wind.speed ?? 1;
16
+ if (!Number.isFinite(speed) || speed <= 0)
17
+ throw new Error("Model wind speed must be finite and positive");
18
+ const height = wind.height ?? fallbackHeight;
19
+ if (!Number.isFinite(height) || height <= 0)
20
+ throw new Error("Model wind height must be finite and positive");
21
+ const direction = new THREE.Vector2(wind.direction?.[0] ?? windDirection.x, wind.direction?.[1] ?? windDirection.y);
22
+ if (direction.lengthSq() === 0)
23
+ direction.set(1, 0);
24
+ direction.normalize();
25
+ return { strength, speed, height, direction };
26
+ }
27
+ const VERTEX_PARS = /* glsl */ `
28
+ uniform float uJgWindTime;
29
+ uniform vec2 uJgWindDir;
30
+ uniform float uJgWindStrength;
31
+ uniform float uJgWindSpeed;
32
+ uniform float uJgWindHeight;`;
33
+ // Height-ramped sway in object space, phased by the instance's world position so neighbouring
34
+ // plants move on different gusts. Trunks (low vertices) barely move; fronds ride the full travel.
35
+ const VERTEX_SWAY = /* glsl */ `#include <begin_vertex>
36
+ {
37
+ float jgWindT = clamp(position.y / max(uJgWindHeight, 0.001), 0.0, 1.0);
38
+ float jgWindRamp = jgWindT * jgWindT;
39
+ #ifdef USE_INSTANCING
40
+ vec3 jgWindWorld = (instanceMatrix * vec4(transformed, 1.0)).xyz;
41
+ #else
42
+ vec3 jgWindWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;
43
+ #endif
44
+ float jgWindPhase = dot(jgWindWorld.xz, vec2(0.17, 0.13)) + uJgWindTime * uJgWindSpeed;
45
+ float jgWindGust = sin(jgWindPhase) * 0.6 + sin(jgWindPhase * 0.47 + 2.3) * 0.4;
46
+ float jgWindFlutter = sin(uJgWindTime * 6.3 + jgWindWorld.x * 0.9 + jgWindWorld.z * 1.3) * 0.18;
47
+ vec2 jgWindPush = uJgWindDir * (jgWindGust + jgWindFlutter) * uJgWindStrength * jgWindRamp;
48
+ transformed.x += jgWindPush.x;
49
+ transformed.z += jgWindPush.y;
50
+ }`;
51
+ /**
52
+ * Compiles height-ramped wind sway into a standard material's vertex shader. Idempotent per
53
+ * material; the clock and direction are shared module uniforms, so the whole scene advances with
54
+ * one write per frame and no per-material bookkeeping.
55
+ * @capability model-wind sway a foliage model in the shared gust field
56
+ */
57
+ export function applyModelWind(material, wind) {
58
+ if (material.userData[WIND_KEY] !== undefined)
59
+ return material;
60
+ const uniforms = {
61
+ uJgWindTime: windClock,
62
+ uJgWindDir: { value: wind.direction.clone() },
63
+ uJgWindStrength: { value: wind.strength },
64
+ uJgWindSpeed: { value: wind.speed },
65
+ uJgWindHeight: { value: wind.height },
66
+ };
67
+ material.userData[WIND_KEY] = uniforms;
68
+ const previousCompile = material.onBeforeCompile;
69
+ const previousKey = material.customProgramCacheKey;
70
+ material.onBeforeCompile = (shader, renderer) => {
71
+ previousCompile.call(material, shader, renderer);
72
+ Object.assign(shader.uniforms, uniforms);
73
+ shader.vertexShader = shader.vertexShader
74
+ .replace("#include <common>", `#include <common>\n${VERTEX_PARS}`)
75
+ .replace("#include <begin_vertex>", VERTEX_SWAY);
76
+ };
77
+ material.customProgramCacheKey = () => `${previousKey.call(material)}|jg-wind:v1`;
78
+ material.needsUpdate = true;
79
+ return material;
80
+ }
81
+ /** Applies wind to every standard material under a cloned model. @internal */
82
+ export function applyModelWindToScene(root, wind, fallbackHeight = 4) {
83
+ const resolved = resolveModelWind(wind, fallbackHeight);
84
+ if (resolved === null)
85
+ return;
86
+ root.traverse((node) => {
87
+ const mesh = node;
88
+ if (!mesh.isMesh)
89
+ return;
90
+ const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
91
+ for (const material of list) {
92
+ if (material.isMeshStandardMaterial === true)
93
+ applyModelWind(material, resolved);
94
+ }
95
+ });
96
+ }
97
+ /** Advances the shared gust clock once per frame; mount one per 3D world. */
98
+ export function WindClock() {
99
+ useFrame(({ clock }) => {
100
+ windClock.value = clock.getElapsedTime();
101
+ });
102
+ return null;
103
+ }
@@ -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,2 @@
1
+ /** Registers the `decal` marker renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
2
+ export declare function registerDecalRenderer(): void;