@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 +131 -0
- package/README.md +73 -0
- package/package.json +3 -2
- package/src/debrisgen/debrisPresets.js +7 -0
- package/src/environment/environmentPresets.js +19 -0
- package/src/environment/environmentSettings.js +10 -0
- package/src/environment/environmentTextureResolver.js +3 -0
- package/src/environment/index.js +1 -0
- package/src/index.js +7 -0
- package/src/post/postProcessing.js +28 -0
- package/src/rockgen/rockgenPresets.js +8 -0
- package/src/rockgen/rockgenSettings.js +47 -0
- package/src/shaders-tsl/environment.js +60 -0
- package/src/shaders-tsl/water.js +54 -1
- package/src/sky/stylizedSky.js +49 -1
- package/src/stylizedTerrain.js +601 -0
- package/src/stylizedWorld.js +500 -0
- package/src/vegetation/index.js +2 -0
- package/src/vegetation/scatter.js +297 -0
- package/src/vegetation/stylizedFlowers.js +48 -1
- package/src/vegetation/stylizedForest.js +448 -0
- package/src/vegetation/stylizedGrass.js +50 -2
- package/src/vegetation/stylizedTree.js +71 -2
- package/src/vegetation/stylizedTreeFoliage.js +11 -2
- package/src/water/waterScenePasses.js +5 -1
- package/src/water/waterSettings.js +10 -0
- package/src/water/waterSurface.js +27 -0
- package/src/worldCollision.js +94 -0
- package/src/worldMinimap.js +152 -0
- package/src/worldPresets.js +122 -0
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.
|
|
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,
|
package/src/environment/index.js
CHANGED
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)
|