@call-me-sensei/toonlab 0.1.1 → 0.2.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/AGENTS.md ADDED
@@ -0,0 +1,131 @@
1
+ # ToonLab — guide for AI coding agents
2
+
3
+ You are helping a developer use `@call-me-sensei/toonlab` (alias: `toonlab`),
4
+ a stylized anime game kit for Three.js (WebGPU-first TSL materials, WebGL2
5
+ fallback; 1 world unit = 1 meter). The look is NOT in any single API — it
6
+ emerges from the assembly: sky feeds water reflections, an aligned sun rig +
7
+ three-layer fog set the palette, cloud shadows tie ground/canopy/water
8
+ together. Skipping the assembly produces gray, flat, "programmer-art" scenes.
9
+
10
+ ## Golden path — a full world in minutes
11
+
12
+ ```js
13
+ import { createStylizedTerrain, createStylizedWorld } from '@call-me-sensei/toonlab';
14
+
15
+ // 1. Terrain: any seed is a valid world. waterCoverage is the "more water"
16
+ // knob; height/depth set mountain amplitude and basin depth; size takes
17
+ // a number or { x, z }; floatingIslands / sinkholes are one option each.
18
+ const terrain = createStylizedTerrain({
19
+ seed: 42,
20
+ size: 1000,
21
+ archetype: 'terracedKarst', // 'lakeland' | 'alpine' | 'rollingPlains' | 'archipelago'
22
+ });
23
+ const terrainRoot = new THREE.Group();
24
+ terrainRoot.add(terrain.root);
25
+ scene.add(terrainRoot);
26
+
27
+ // 2. Everything else: environment shading, aligned sun + shadows, sky,
28
+ // water, LOD forests, follow-window grass, cloud shadows, collision.
29
+ const world = await createStylizedWorld({
30
+ renderer, scene, camera,
31
+ terrain: { heightAt: terrain.heightAt, root: terrainRoot, size: terrain.meshExtent },
32
+ water: { level: terrain.waterLevel },
33
+ followTarget: characterRoot, // splashes, wakes, grass push
34
+ });
35
+ characterRoot.position.copy(terrain.spawn); // probed: walkable, near shore
36
+ // render loop, before rendering:
37
+ world.update(delta);
38
+ ```
39
+
40
+ **Bring your own terrain** (no generator): the ONLY contract is a pure
41
+ `heightAt(x, z) → meters` plus a displaced mesh under `terrain.root` with
42
+ `frustumCulled = false`. Everything else (masks, scatter, collision,
43
+ minimap) derives from `heightAt` and `water.level`. Even
44
+ `heightAt = (x, z) => 12 * Math.sin(x / 90) * Math.cos(z / 90)` gives a
45
+ complete shaded, forested, swimmable world.
46
+
47
+ Character: `applyToonShader(root, { settings: createToonSettings({ preset:
48
+ 'call_me_sensei' }) })` + `createCharacterRenderPasses` (call
49
+ `passes.update()` per frame). Post: `createPostProcessingPipeline` with
50
+ preset `call_me_sensei`; `post.render(delta)` replaces `renderer.render`.
51
+ Minimap: `createWorldMinimap({ heightAt, size, waterLevel, onPick })` —
52
+ call `minimap.setPlayer(x, z, heading)` per frame.
53
+ Every cluster has a `default` and a studio-managed `call_me_sensei` preset.
54
+
55
+ ## Quality rules (validated against reference-class modern anime worlds)
56
+
57
+ - Align the visible sky sun and the light rig, and match the hour: 2 PM sun
58
+ is HIGH (`sunDirection` y ≈ 0.8, warm-white); golden hour is LOW (y ≈ 0.4).
59
+ - Three-layer atmosphere: scene.fog (set by createStylizedWorld) +
60
+ environment heightFog (`heightFogColor` luminous blue [0.63,0.8,0.98],
61
+ density ≈ 0.0012–0.0016, falloff ≈ 400) + post depthCue (~0.3, blue).
62
+ White or absent fog is the #1 giveaway of a bad scene. EVERY custom
63
+ surface must join the height-fog layer or it reads as pasted on —
64
+ `createStylizedWorld` wires water and forest impostors automatically
65
+ (`setDistanceFog`); lower the density per aerial view or flyovers gray out.
66
+ - Cast shadows: terrain `castShadow = true`, near rocks both flags, forests
67
+ `lod: { castShadow: true }` (only live near trees cast — correct).
68
+ - Vividness is palette + saturation, not brightness: environment
69
+ `saturation ≈ 1.24`, `exposure ≈ 1.1`, `shadowTintColor [0.6,0.66,0.82]`,
70
+ saturated cerulean zenith, two-tone cumulus with blue-shaded bottoms,
71
+ green-dominant canopy list with ONE gold accent variant (muddy autumn
72
+ mixes read as confetti), turquoise water ramp.
73
+ - Cluster forests with `createNoisePatchMask`; tree `size` 2.5–4.
74
+ - Cliffs: steep faces need material, not flat paint — set
75
+ `material.userData.envTriplanarMap` (painted stone tile) +
76
+ `triplanarDetail: 1`, `triplanarDetailScale ≈ 14`,
77
+ `triplanarEdgeHighlight` for painted lip highlights; keep per-vertex
78
+ paint LOW-frequency (bands finer than the mesh grid alias into zigzag
79
+ triangles) and gate meadow/gold paint hard by slope.
80
+ - Never let ground hover within ±1.6 m of water level over large areas
81
+ (broken water slivers); end the map in a hazy mountain rim;
82
+ `frustumCulled = false` on world-scale meshes.
83
+ - Budgets: trees ≤ 3,000 via `StylizedForest` (16-vert billboard far LOD —
84
+ pass `renderer` or you get the expensive legacy path), grass ≤ 320k
85
+ blades in a follow window, startup < 10 s. Give every repeated mesh set
86
+ (rocks, cliff decor) a hi/lo distance LOD using TRUE 3D distance so
87
+ aerial cameras demote everything. Exclude above-water dressing from
88
+ water passes: `userData.waterExclude` (all passes) or
89
+ `waterGrabExclude` (refraction only, keeps the reflection).
90
+ - Perf triage: add URL toggles per system and read the FPS meter — the
91
+ water scene passes (grab/depth/reflection) multiply every other cost, so
92
+ measure with and without water first. Scale them via
93
+ `water.settings.passes = { reflectionScale: 0.4, sceneColorScale: 0.6 }`.
94
+
95
+ ## Subpath imports
96
+
97
+ `/toon` `/environment` `/water` `/vegetation` (incl. scatter helpers +
98
+ `StylizedForest`) `/sky` `/post` `/character` `/loaders` `/rockgen`
99
+ `/debrisgen` `/debug`; root adds `createStylizedTerrain`,
100
+ `createStylizedWorld`, `createWorldCollision`, `createWorldMinimap`,
101
+ `resolveWorldPreset`.
102
+
103
+ ## Symptom table — check before debugging blind
104
+
105
+ | Looks like | Cause → fix |
106
+ |---|---|
107
+ | Terrain gray/flat | environment shader never applied → put meshes under `terrain.root` |
108
+ | Distant trees sharp saturated dots on hazed mountains | surface missing the height-fog layer → update ToonLab (impostors/water auto-wired via `setDistanceFog`); custom surfaces must join it |
109
+ | Distant water bright band "cutting into" mountains | same fog-layer mismatch → `waterSurface.setDistanceFog({ color, density })` |
110
+ | Giant white "iceberg" wedges at far shorelines | outdated ToonLab (swash film climbed steep banks) → update |
111
+ | Full-detail trees popping in aerial views | LOD by horizontal distance → update ToonLab (3D distance) |
112
+ | Gold/orange tree with green-shadow or pink-crown leaves | outdated ToonLab (palette derivation broke on warm hues) → update |
113
+ | Billboards upside down / trunk-up | render-target bakes are written top-down → update ToonLab |
114
+ | Trees like confetti from the air | uniform scatter → `createNoisePatchMask`; palette too mixed → green-dominant + one gold |
115
+ | White valley blotches | white height fog → sky-blue `heightFogColor` |
116
+ | Fog has no effect | `heightFogFalloff` too small → ≈ 400 |
117
+ | Everything pale/gray from the air | one fog density for all views → lower `heightFogDensity` for aerial cameras (terrain uniforms + `water/forest.setDistanceFog`) |
118
+ | Cliff walls flat, untextured up close | planar UVs stretch on walls → `envTriplanarMap` + `triplanarDetail` |
119
+ | Zigzag triangles on cliff walls | per-vertex paint finer than the grid, or hue bleeding through stone → low-frequency bands; luminance-only tint is built in |
120
+ | Flat light, no shadows | vertical/misaligned sun, nothing casts → align sun, enable castShadow |
121
+ | Mountains vanish when centered | frustum culling on displaced meshes → `frustumCulled = false` |
122
+ | Minute-long startup | unique tree per placement → `StylizedForest` |
123
+ | ~20 fps in a big world | full-res meshes in every water pass → billboard forests (pass `renderer`), hi/lo rock LOD, `waterExclude`/`waterGrabExclude`, pass scales, `?dpr=1` on retina |
124
+ | Character walks through rocks/trees | blockers unregistered → `world.collision.addCircles([{x,z,radius}])` + `world.collision.resolve(character.position, 0.35)` per frame (trunks are pre-registered) |
125
+ | Character floats over/sinks into water | float on `water.getHeightAt(x, z)` with chest at the waterline; calm swim default, fast stroke on Shift, `action.timeScale = clamp(speed/1.7, 0.75, 1.35)` |
126
+
127
+ Full runbook with budgets and verification workflow:
128
+ `agents/skills/*/outdoor-world/SKILL.md` in the repo; complete reference
129
+ app: `examples/outdoor-world/`. Verify by headless Playwright screenshot
130
+ (`--enable-unsafe-webgpu --enable-gpu`), not by assumption — and LOOK at
131
+ the images.
package/README.md CHANGED
@@ -14,6 +14,18 @@ subpath exports per cluster).
14
14
 
15
15
  ## Quickstart
16
16
 
17
+ **No install** — use the hosted labs at **[toonlab.io](https://toonlab.io)**:
18
+ tune character and environment shaders, design trees and rocks, and export
19
+ presets straight from the browser.
20
+
21
+ **As a library** in your own Three.js app:
22
+
23
+ ```bash
24
+ npm install @call-me-sensei/toonlab
25
+ ```
26
+
27
+ **Run the labs locally** (this repo):
28
+
17
29
  ```bash
18
30
  git clone https://github.com/call-me-sensei/toonlab.git && cd toonlab
19
31
  npm install
@@ -38,6 +50,11 @@ the HUD Scene select:
38
50
  heightfields, sculpt edits, and GLB export.
39
51
  - **Tree Lab** (`/tree-lab/`) — procedural stylized trees,
40
52
  flowers, sketches, recipes, and GLB export.
53
+ - **Outdoor World** (`/examples/outdoor-world/`) — the flagship example: a
54
+ seeded 1×1 km open world built entirely from the public library API —
55
+ generated terrain, forests, lakes, cliffs, a swimmable character, and a
56
+ click-to-travel minimap. Re-roll it from the URL:
57
+ `?seed=42&archetype=lakeland&water=0.4&islands=3`.
41
58
 
42
59
  Every URL parameter has a HUD control, lab state persists per lab in
43
60
  `localStorage`, and **Reset Lab** clears it. Point any lab at your own model
@@ -64,6 +81,59 @@ embedded animation clips.
64
81
 
65
82
  ## Library usage
66
83
 
84
+ ### A complete open world in one screen of code
85
+
86
+ ```js
87
+ import { createStylizedTerrain, createStylizedWorld, createWorldMinimap } from '@call-me-sensei/toonlab';
88
+
89
+ // Seeded terrain generator — ANY seed is a valid, playable world. One knob
90
+ // per big idea: waterCoverage (how much water), height (mountain range),
91
+ // depth (basins), size (number or { x, z }), floatingIslands, sinkholes.
92
+ const terrain = createStylizedTerrain({ seed: 42, size: 1000, archetype: 'terracedKarst' });
93
+ const terrainRoot = new THREE.Group();
94
+ terrainRoot.add(terrain.root);
95
+ scene.add(terrainRoot);
96
+
97
+ // Environment shading, aligned sun + real shadows, sky, anime water, LOD
98
+ // forests (billboard far trees), follow-window grass, cloud shadows,
99
+ // unified three-layer fog, and collision — all on by default.
100
+ const world = await createStylizedWorld({
101
+ renderer, scene, camera,
102
+ terrain: { heightAt: terrain.heightAt, root: terrainRoot, size: terrain.meshExtent },
103
+ water: { level: terrain.waterLevel },
104
+ followTarget: character, // your character root (optional): splashes, wakes, grass push
105
+ });
106
+ character.position.copy(terrain.spawn); // probed: walkable, near a shore
107
+
108
+ const clock = new THREE.Clock();
109
+ renderer.setAnimationLoop(() => {
110
+ world.update(clock.getDelta());
111
+ renderer.render(scene, camera);
112
+ });
113
+ ```
114
+
115
+ **Bring your own terrain instead** — the generator is optional. The whole
116
+ contract is a pure `heightAt(x, z)` in meters plus your displaced mesh under
117
+ `terrain.root`; masks, scatter, collision, and the minimap all derive from
118
+ it:
119
+
120
+ ```js
121
+ const heightAt = (x, z) => 12 * Math.sin(x / 90) * Math.cos(z / 90); // yours
122
+ const world = await createStylizedWorld({
123
+ renderer, scene, camera,
124
+ terrain: { heightAt, root: myTerrainRoot, size: 1200 },
125
+ water: { level: 0 },
126
+ });
127
+ ```
128
+
129
+ Add a clickable minimap with `createWorldMinimap({ heightAt, size,
130
+ waterLevel, onPick })`, and solid rocks/trees with the built-in
131
+ `world.collision` (`addCircles` for your own props, `resolve(position,
132
+ radius)` per frame). Archetypes: `terracedKarst`, `lakeland`, `alpine`,
133
+ `rollingPlains`, `archipelago`.
134
+
135
+ ### Individual clusters
136
+
67
137
  ```js
68
138
  import { applyToonShader, createToonSettings } from '@call-me-sensei/toonlab/toon';
69
139
 
@@ -107,6 +177,9 @@ stack, with WebGL2 fallback through the same TSL path.
107
177
  - [Post-processing](docs/post-processing.md)
108
178
  - [Characters and animation](docs/characters.md)
109
179
  - [Debug panel](docs/debug-panel.md)
180
+ - [World scale and world presets](docs/world-scale.md) — the meters
181
+ convention, open-world scale presets, rock quality tiers, and vegetation
182
+ scatter helpers.
110
183
  - [Settings reference](docs/settings-reference.md) — every tunable field,
111
184
  generated from the schemas (`node scripts/generate-settings-reference.mjs`).
112
185
  - [Shader constants](docs/shader-constants.md) — the deliberately unexposed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@call-me-sensei/toonlab",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Stylized (anime/toon) game starter kit for Three.js: character toon shading, environment shading, water, vegetation, sky, and post-processing",
5
5
  "license": "MIT",
6
6
  "author": "Hyperbond Studio PTE. LTD. (Call Me Sensei)",
@@ -29,7 +29,8 @@
29
29
  "src",
30
30
  "README.md",
31
31
  "LICENSE",
32
- "ATTRIBUTION.md"
32
+ "ATTRIBUTION.md",
33
+ "AGENTS.md"
33
34
  ],
34
35
  "exports": {
35
36
  ".": "./src/index.js",
@@ -15,6 +15,13 @@ function preset(id, label, type, variant, description, overrides = {}) {
15
15
  }
16
16
 
17
17
  export const BUILT_IN_DEBRIS_PRESETS = Object.freeze([
18
+ // Studio-managed signature debris, curated by Call Me Sensei and updated
19
+ // over releases. Currently the bleached-driftwood look.
20
+ preset('call_me_sensei', 'Call Me Sensei', 'wood', 'driftwood', 'Studio-managed signature debris, updated over releases.', {
21
+ asset: { count: 1, seed: 7301, spread: 0 },
22
+ shape: { branchiness: 0.38, crookedness: 0.86, length: 3.05, splinters: 0.8, thickness: 0.23 },
23
+ surface: { accentColor: [0.52, 0.39, 0.25], primaryColor: [0.52, 0.43, 0.31], secondaryColor: [0.78, 0.68, 0.5], variation: 0.24 },
24
+ }),
18
25
  preset('bleached-driftwood', 'Bleached driftwood', 'wood', 'driftwood', 'Salt-worn pale wood with broken fibers.', {
19
26
  asset: { count: 1, seed: 7301, spread: 0 },
20
27
  shape: { branchiness: 0.38, crookedness: 0.86, length: 3.05, splinters: 0.8, thickness: 0.23 },
@@ -441,3 +441,22 @@ registerEnvironmentPreset('showcase', {
441
441
  timeOfDayHour: 15,
442
442
  },
443
443
  });
444
+
445
+ // Studio-managed signature look, curated by Call Me Sensei and updated over
446
+ // releases (unlike 'showcase', this is a production grade, not a feature
447
+ // tour). Community presets register alongside it via
448
+ // registerEnvironmentPreset / environment preset documents.
449
+ registerEnvironmentPreset('call_me_sensei', {
450
+ label: 'Call Me Sensei',
451
+ parameters: {
452
+ ambientProbeBlend: 0.35,
453
+ aoWarmth: 0.5,
454
+ heightFogDensity: 0.006,
455
+ skyTintStrength: 0.35,
456
+ },
457
+ rig: {
458
+ probe: true,
459
+ sun: true,
460
+ timeOfDayHour: 14,
461
+ },
462
+ });
@@ -121,6 +121,9 @@ export const DEFAULT_ENVIRONMENT_PARAMETERS = Object.freeze({
121
121
  spotLightStrength: null,
122
122
  sunBoost: null,
123
123
  sunBoostColor: null,
124
+ triplanarDetail: null,
125
+ triplanarDetailScale: null,
126
+ triplanarEdgeHighlight: null,
124
127
  untexturedGradientStrength: null,
125
128
  vertexAoStrength: null,
126
129
  });
@@ -219,6 +222,9 @@ const FIELD_LABEL_OVERRIDES = Object.freeze({
219
222
  spotLightStrength: 'Spot Light',
220
223
  sunBoost: 'Sun Boost',
221
224
  sunBoostColor: 'Sun Boost Color',
225
+ triplanarDetail: 'Triplanar Detail',
226
+ triplanarDetailScale: 'Triplanar Detail Scale',
227
+ triplanarEdgeHighlight: 'Rock Edge Highlight',
222
228
  windowCutout: 'Window Cutout',
223
229
  });
224
230
 
@@ -242,6 +248,7 @@ function rangeForParameter(key) {
242
248
  if (key === 'heightFogDensity') return { max: 0.5, min: 0, step: 0.001 };
243
249
  if (key === 'heightFogFalloff') return { max: 30, min: 0.05, step: 0.05 };
244
250
  if (key === 'planarReflectionFresnel') return { max: 8, min: 0.1, step: 0.05 };
251
+ if (key === 'triplanarDetailScale') return { max: 64, min: 0.25, step: 0.25 };
245
252
  if (key === 'exposure' || key === 'saturation') return { max: 2, min: 0, step: 0.01 };
246
253
  if (key.includes('Strength') || key.includes('Influence')) return { max: 2, min: 0, step: 0.01 };
247
254
  if (key.includes('LightStrength')) return { max: 4, min: 0, step: 0.01 };
@@ -480,6 +487,9 @@ export function applyEnvironmentSettingsToMaterial(material, settingsInput = {})
480
487
  setNumberUniform(uniforms, 'skyTintStrength', parameters.skyTintStrength);
481
488
  setNumberUniform(uniforms, 'spotLightStrength', parameters.spotLightStrength);
482
489
  setNumberUniform(uniforms, 'sunBoost', parameters.sunBoost);
490
+ setNumberUniform(uniforms, 'triplanarDetail', parameters.triplanarDetail);
491
+ setNumberUniform(uniforms, 'triplanarDetailScale', parameters.triplanarDetailScale);
492
+ setNumberUniform(uniforms, 'triplanarEdgeHighlight', parameters.triplanarEdgeHighlight);
483
493
  setColorUniform(uniforms, 'heightFogColor', parameters.heightFogColor);
484
494
  setColorUniform(uniforms, 'interiorOcclusionColor', parameters.interiorOcclusionColor);
485
495
  setColorUniform(uniforms, 'leftSideShadowColor', parameters.leftSideShadowColor);
@@ -154,6 +154,9 @@ export async function resolveEnvironmentTextureSet(mat) {
154
154
  normalScale: mat?.normalScale?.isVector2 ? mat.normalScale.clone() : new THREE.Vector2(1, 1),
155
155
  packedMap,
156
156
  resolvedDiffuseMap: Boolean(resolvedDiffuseMap),
157
+ // Opt-in world-projected material for steep faces (cliffs, rock walls):
158
+ // sampled triplanar and blended by slope where triplanarDetail > 0.
159
+ triplanarMap: userData.envTriplanarMap ?? null,
157
160
  // True when the material arrived with no real color texture at all — the
158
161
  // untextured/flat-color input class that gets the designed gradient look.
159
162
  untextured: baseMap === fallbackEnvironmentWhiteTexture,
@@ -6,3 +6,4 @@ export * from './environmentRigs.js';
6
6
  export * from './environmentTimeOfDay.js';
7
7
  export * from './environmentAmbientProbe.js';
8
8
  export * from './environmentPlanarReflection.js';
9
+ export * from './environmentSunShadowPass.js';
package/src/index.js CHANGED
@@ -19,3 +19,10 @@ export * from './character/characterRig.js';
19
19
  export * from './character/freestyleSwimClip.js';
20
20
  export * from './rockgen/index.js';
21
21
  export * from './debrisgen/index.js';
22
+ export * from './vegetation/scatter.js';
23
+ export * from './worldPresets.js';
24
+ export * from './stylizedWorld.js';
25
+ export * from './vegetation/stylizedForest.js';
26
+ export * from './worldCollision.js';
27
+ export * from './worldMinimap.js';
28
+ export * from './stylizedTerrain.js';
@@ -104,6 +104,30 @@ export const POST_PROCESSING_PRESETS = Object.freeze({
104
104
  warmth: 0.0,
105
105
  },
106
106
  },
107
+ // Studio-managed signature grade, curated by Call Me Sensei and updated
108
+ // over releases. Currently equal to the softAnime grade.
109
+ call_me_sensei: {
110
+ features: {
111
+ bloom: false,
112
+ colorGrade: false,
113
+ enabled: true,
114
+ vignette: true,
115
+ verticalGrade: false,
116
+ },
117
+ parameters: {
118
+ bloomRadius: 0.08,
119
+ bloomStrength: 0.0,
120
+ bloomThreshold: 0.992,
121
+ bottomDark: 0.0,
122
+ contrast: 1.0,
123
+ exposure: 1.0,
124
+ saturation: 1.0,
125
+ strength: 0.45,
126
+ topLight: 0.0,
127
+ vignetteStrength: 0.018,
128
+ warmth: 0.0,
129
+ },
130
+ },
107
131
  debugEdges: {
108
132
  features: {
109
133
  depthCue: true,
@@ -602,6 +626,10 @@ export function createPostProcessingPresetDocument(id, definition = {}) {
602
626
  }
603
627
 
604
628
  const BUILT_IN_POST_PROCESSING_PRESET_METADATA = Object.freeze({
629
+ call_me_sensei: Object.freeze({
630
+ description: 'Studio-managed signature grade, curated by Call Me Sensei and updated over releases.',
631
+ label: 'Call Me Sensei',
632
+ }),
605
633
  custom: Object.freeze({
606
634
  description: 'Neutral starting point that expects host-supplied feature and parameter overrides.',
607
635
  label: 'Custom',
@@ -557,3 +557,11 @@ registerRockgenPreset('shard-monolith', {
557
557
  topSlopeStart: 0.55,
558
558
  },
559
559
  });
560
+
561
+ // Studio-managed signature rock, curated by Call Me Sensei and updated over
562
+ // releases. Currently the boulder look under the managed label. Community
563
+ // presets register alongside it via registerRockgenPreset().
564
+ registerRockgenPreset('call_me_sensei', {
565
+ ...resolveRockgenPreset('boulder'),
566
+ label: 'Call Me Sensei',
567
+ });
@@ -336,6 +336,53 @@ export const DEFAULT_ROCKGEN_MESHING_SETTINGS = Object.freeze({
336
336
  sharpFeatures: true,
337
337
  });
338
338
 
339
+ // Quality tiers couple meshing resolution and normal mode for a target
340
+ // viewing context, so hosts pick one word instead of tuning both by hand:
341
+ //
342
+ // - `hero` — close-up set pieces (< 20 m camera): highest resolution, flat
343
+ // faceted normals so planar cuts read as stylized facets.
344
+ // - `gameplayHigh` — open-world gameplay cameras (50–160 m): gradient
345
+ // normals (flat facets go near-black at range under toon lighting) at the
346
+ // default resolutions.
347
+ // - `mobile` — low-end targets: reduced resolutions, gradient normals,
348
+ // sharp-feature solve off.
349
+ export const ROCKGEN_QUALITY_LEVELS = Object.freeze(['hero', 'gameplayHigh', 'mobile']);
350
+
351
+ export const ROCKGEN_QUALITY_PRESETS = Object.freeze({
352
+ gameplayHigh: Object.freeze({
353
+ exportResolution: 224,
354
+ normalsMode: 'gradient',
355
+ previewResolution: 96,
356
+ sharpFeatures: true,
357
+ }),
358
+ hero: Object.freeze({
359
+ exportResolution: 288,
360
+ normalsMode: 'flat',
361
+ previewResolution: 128,
362
+ sharpFeatures: true,
363
+ }),
364
+ mobile: Object.freeze({
365
+ exportLods: false,
366
+ exportResolution: 128,
367
+ normalsMode: 'gradient',
368
+ previewResolution: 56,
369
+ sharpFeatures: false,
370
+ }),
371
+ });
372
+
373
+ /**
374
+ * Returns full meshing settings for a quality tier merged over
375
+ * {@link DEFAULT_ROCKGEN_MESHING_SETTINGS}; unknown names return the plain
376
+ * defaults. Spread the result into a preset's `meshing`:
377
+ *
378
+ * const preset = resolveRockgenPreset('call_me_sensei');
379
+ * preset.meshing = { ...preset.meshing, ...resolveRockgenQuality('gameplayHigh') };
380
+ */
381
+ export function resolveRockgenQuality(name) {
382
+ const tier = ROCKGEN_QUALITY_PRESETS[name];
383
+ return { ...DEFAULT_ROCKGEN_MESHING_SETTINGS, ...(tier ?? {}) };
384
+ }
385
+
339
386
  export const ROCKGEN_SETTING_GROUPS = Object.freeze([
340
387
  Object.freeze({
341
388
  description: 'Base primitive the rock piece is displaced from.',
@@ -30,6 +30,7 @@
30
30
 
31
31
  import * as THREE from 'three';
32
32
  import {
33
+ abs,
33
34
  attribute,
34
35
  cameraFar,
35
36
  cameraNear,
@@ -50,6 +51,7 @@ import {
50
51
  mix,
51
52
  normalize,
52
53
  normalLocal,
54
+ normalWorld,
53
55
  positionWorld,
54
56
  pow,
55
57
  select,
@@ -174,6 +176,7 @@ export function createEnvironmentNodeMaterial({
174
176
  skyTopTint: uniform(new THREE.Color(0.86, 0.96, 1.08)),
175
177
  sunBoostColor: uniform(new THREE.Color(1.0, 0.78, 0.42)),
176
178
 
179
+ triplanarMapTex: texture(textureSet.triplanarMap ?? fallbackEnvironmentWhiteTexture),
177
180
  normalMapTex: texture(textureSet.normalMap ?? fallbackEnvironmentNormalTexture),
178
181
  normalMapStrength: uniform(0.8),
179
182
  normalMapScale: uniform(textureSet.normalScale ?? new THREE.Vector2(1, 1)),
@@ -199,6 +202,9 @@ export function createEnvironmentNodeMaterial({
199
202
  heightFogDensity: uniform(0.0),
200
203
  heightFogFalloff: uniform(6.0),
201
204
  heightFogColor: uniform(new THREE.Color(0.75, 0.82, 0.92)),
205
+ triplanarDetail: uniform(0.0),
206
+ triplanarDetailScale: uniform(8.0),
207
+ triplanarEdgeHighlight: uniform(0.6),
202
208
  planarReflectionStrength: uniform(isGlossFloor ? 0.3 : 0.0),
203
209
  planarReflectionFresnel: uniform(2.4),
204
210
 
@@ -253,6 +259,7 @@ export function createEnvironmentNodeMaterial({
253
259
  normalMapTex: u.normalMapTex,
254
260
  packedMap: u.packedMap,
255
261
  planarReflectionMap: shared.planarReflectionMap,
262
+ triplanarMapTex: u.triplanarMapTex,
256
263
  };
257
264
 
258
265
  const material = new NodeMaterial();
@@ -388,6 +395,22 @@ export function createEnvironmentNodeMaterial({
388
395
 
389
396
  material.fragmentNode = Fn(() => {
390
397
  const texel = tex.baseMap.sample(vUv).toVar();
398
+ // Triplanar detail: re-sample the base map projected in world space and
399
+ // blended by the surface normal, so steep faces (cliff walls, terrace
400
+ // sides) keep the same texture density as the ground. A heightfield's
401
+ // planar UVs compress an entire wall into a sliver of the map — up
402
+ // close the wall reads as untextured flat paint. Opt-in: 0 disables.
403
+ If(u.triplanarDetail.greaterThan(0.0), () => {
404
+ const scale = max(u.triplanarDetailScale, 0.001);
405
+ const weights = pow(abs(normalWorld), vec3(4.0)).toVar();
406
+ const weightSum = max(weights.x.add(weights.y).add(weights.z), 0.0001);
407
+ const tri = tex.baseMap.sample(vWorldPosition.zy.div(scale)).rgb
408
+ .mul(weights.x)
409
+ .add(tex.baseMap.sample(vWorldPosition.xz.div(scale)).rgb.mul(weights.y))
410
+ .add(tex.baseMap.sample(vWorldPosition.xy.div(scale)).rgb.mul(weights.z))
411
+ .div(weightSum);
412
+ texel.rgb.assign(mix(texel.rgb, tri, clamp(u.triplanarDetail, 0.0, 1.0)));
413
+ });
391
414
  if (flags.useVertexColors) {
392
415
  // USE_COLOR / USE_COLOR_ALPHA: vertexColor() yields w = 1 for vec3
393
416
  // color attributes, so the alpha multiply is a no-op exactly when the
@@ -396,6 +419,43 @@ export function createEnvironmentNodeMaterial({
396
419
  texel.rgb.mulAssign(vColor.rgb);
397
420
  texel.a.mulAssign(vColor.a);
398
421
  }
422
+ if (textureSet.triplanarMap) {
423
+ // Dedicated steep-face material (userData.envTriplanarMap): a painted
424
+ // stone/cliff diffuse sampled triplanar in world space and blended in
425
+ // by slope. Flats keep the ground detail; a share of the painted
426
+ // vertex tint bleeds through so strata bands and baked haze still
427
+ // modulate the stone at range.
428
+ If(u.triplanarDetail.greaterThan(0.0), () => {
429
+ const scale = max(u.triplanarDetailScale, 0.001);
430
+ const weights = pow(abs(normalWorld), vec3(4.0)).toVar();
431
+ const weightSum = max(weights.x.add(weights.y).add(weights.z), 0.0001);
432
+ const stone = tex.triplanarMapTex.sample(vWorldPosition.zy.div(scale)).rgb
433
+ .mul(weights.x)
434
+ .add(tex.triplanarMapTex.sample(vWorldPosition.xz.div(scale)).rgb.mul(weights.y))
435
+ .add(tex.triplanarMapTex.sample(vWorldPosition.xy.div(scale)).rgb.mul(weights.z))
436
+ .div(weightSum).toVar();
437
+ // Early stone takeover (0.12 ≈ 29°): the grass↔stone transition band
438
+ // is where the terrain triangulation shows — per-vertex meadow/gold
439
+ // paint interpolating across wall triangles reads as green sawtooth
440
+ // wedges, so the band must be narrow and mostly stone.
441
+ const steep = smoothstep(0.12, 0.3, abs(normalWorld.y).oneMinus());
442
+ // Tint by the vertex paint's LUMINANCE only: brightness variation
443
+ // (strata, haze) carries through, but its hue never does — green
444
+ // tread color bleeding into wall stone is the sawtooth's other half.
445
+ const paintLum = dot(texel.rgb, vec3(0.299, 0.587, 0.114));
446
+ const tinted = stone.mul(mix(vec3(1.0), vec3(paintLum).mul(1.7), 0.35));
447
+ texel.rgb.assign(mix(texel.rgb, tinted, clamp(u.triplanarDetail, 0.0, 1.0).mul(steep)));
448
+ // Edge highlighting: hand-painted rock brightens its convex lips.
449
+ // steep·(1−steep) peaks exactly on the wall↔flat transition band —
450
+ // the rounded top edge of a cliff or terrace — with no baked
451
+ // curvature data and no per-vertex aliasing. Warm, slightly
452
+ // saturated lift like a painted highlight.
453
+ const lip = steep.mul(steep.oneMinus()).mul(4.0)
454
+ .mul(clamp(normalWorld.y, 0.0, 1.0))
455
+ .mul(clamp(u.triplanarEdgeHighlight, 0.0, 2.0));
456
+ texel.rgb.mulAssign(mix(vec3(1.0), vec3(1.26, 1.19, 1.04), lip));
457
+ });
458
+ }
399
459
 
400
460
  Discard(
401
461
  u.enableFoliageCutout.greaterThan(0.5)