# Park zones mood and lighting tuning

This is a practical manual for re-grading the two shipped Park zones — Coral
Bloom (`coral-bloom`) and Cloud Terrace (`cloud-terrace`) — by trial and
error: which knob lives in which file, what it does visually, and which
direction to move it. It assumes you have read
[Park zones 3D techniques](./park-zones-3d-techniques.md) for the rendering
architecture; here we only touch art-direction values, never structure. Every
value quoted below is the current shipped one, so you can diff your edit
against a known baseline.

## The mood tuning panel (dev-only)

The fastest way to find values is not editing files at all. The park ships a
dev-only mood tuning panel, and it is the preferred exploration loop:

1. Start the dev server (`pnpm --filter park dev`) and open the zone you are
   grading.
2. Press `M`. A DOM panel opens with sliders and color pickers for the active
   zone's fog, sun and hemisphere light, sky palette, sun disc, clouds
   (coverage, scale, speed, brightness), and post (bloom, exposure).
3. Drag and watch the frame — every change is a live uniform write or a cheap
   prop update, never a pipeline or shader-graph rebuild. The draft persists
   in localStorage across reloads.
4. When the mood reads right, press «کپی JSON». The export is shaped for
   baking:

```json
{
  "zone": "coral-bloom",
  "skyModel": { "fogFar": 300, "palette": { "zenithColor": "#1b5fd7" } },
  "registryPost": { "bloom": { "strength": 0.55 }, "exposure": 1.12 }
}
```

5. Bake the values into the authored files — by hand or with an AI assistant:
   `skyModel` into the zone's `*-sky-model.ts`, `registryPost` into that
   zone's `art.post` entry in `src/zones/registry.ts`.
6. Run the nearest tests and grade on both backends as usual.

The panel is gated by `import.meta.env.DEV`, so Vite eliminates it from
production builds entirely. Structural values stay build-time and are not
tunable: cloud `octaves`/`detail`, shadow map size and extent, and the sun
disc's angular size (`discInnerCos`/`discOuterCos`). The knob map below stays
the reference for where every value ultimately lives — and for the knobs the
panel deliberately does not expose.

### Day-to-day panel usage

- Every control starts on the zone's authored value, marked «· پیش‌فرض»;
  touching it clears the mark, so the current diff is always visible.
- «بازنشانی» clears every override back to the authored mood and wipes the
  localStorage draft with it.
- The import box («وارد کردن JSON» + «اعمال») restores a pasted export —
  the same validation runs as for a local edit, so malformed input is
  rejected with a message.
- The draft is global, not per zone: tune one zone, copy the JSON, reset,
  then move to the next zone. The export's `zone` field keeps envelopes
  apart.
- To grade with an artist who has no checkout, serve the dev server on your
  LAN and share the link — the panel works on any dev-served page.

### Porting the panel to another environment

The panel is deliberately park-local today — generalizing it before a second
consumer exists would repeat the single-consumer mistake ADR-0060 retired.
The seams are already clean, so porting is cheap when the time comes:

- Reusable as-is: the store mechanics, the `M`-key guard, the panel shell,
  and the export/import round-trip.
- Per environment: its own override type and catalog, a merge/serialize
  model over its own sky-spec type, and the live-handle mapping for its sky
  component.
- Prerequisite: the environment must already hold art direction as data
  (model files) consumed through props and live uniform writes — the pattern
  this document describes. Hardcoded JSX lighting needs that refactor first.
- When a second environment actually adopts the panel, extract the generic
  core into a shared module instead of copying it.

## The iteration loop

1. Start the dev server with `pnpm --filter park dev`. Vite serves on port
   4800 (override with `AL_AMR_DEV_PARK_PORT`).
2. Open the zone you are grading: `/zones/coral-bloom` or
   `/zones/cloud-terrace` (`/` is the Garden Heart).
3. Edit any value below and save — every change hot-reloads; no build, no
   restart, no cache clearing.
4. Know which quality tier you are judging. The session starts at `standard`
   and auto-downgrades to `compatible` after sustained slow frames (two
   consecutive windows with p95 above 24 ms), and never upgrades back.
   `compatible` drops cloud octaves and detail, the bloom pass, and flora
   shadows — a flatter image by construction. `enhanced` is never
   auto-selected. If the frame suddenly looks duller mid-session, the tier
   probably moved, not your edit.
5. Check the second backend deliberately: the renderer prefers WebGPU and
   falls back to WebGL2; append `?al-amr-rendering=force-webgl2` to force the
   WebGL2 lane. Grade on both.
6. Back up before experimenting. The `cloud-terrace` sources may be
   uncommitted in your worktree, so `git checkout --` will not restore them —
   copy the file first (`cp cloud-terrace-sky-model.ts /tmp/backup/`) or
   commit your baseline.

## Three mood layers

Mood comes from three layers, in order of leverage:

1. **The zone's sky model** — `coral-bloom-sky-model.ts` or
   `cloud-terrace-sky-model.ts`. This is roughly 90% of the mood: sky
   palette, fog, sun and hemisphere light, cloud budget. Start here, always.
2. **The zone's post overrides** — the `art.post` entry in
   `environments/park/src/zones/registry.ts`. Only two knobs exist:
   `bloom.strength` (clamped to `[0, 8]`) and `exposure` (clamped to
   `[0.1, 8]`, written to `renderer.toneMappingExposure` as a live uniform
   mutation — never a pipeline rebuild). Global brightness and glow, nothing
   else.
3. **Material colors in the `.tsx` components** — meadow greens, water body
   colors, flora tints. Fine seasoning after the light is right.

Rule of thumb: fix the light before the materials. A scene that looks wrong
under wrong light will look differently wrong under right light.

## Sky palette parameters

Both zones resolve the same `ParkSkySpec` shape from
`environments/park/src/scenes/sky/park-sky-model.ts`. The palette colors the
dome gradient and the clouds; it does not light any surface.

| Parameter          | Coral Bloom | Cloud Terrace | Visual effect                                                                                         |
| ------------------ | ----------- | ------------- | ----------------------------------------------------------------------------------------------------- |
| `zenithColor`      | `#1b5fd7`   | `#4aa8e8`     | Sky directly overhead. Deepen for a bluer, clearer day; lighten toward cyan for a high-key heaven.    |
| `midColor`         | `#6fb2f0`   | `#8ecbf4`     | Mid-sky blend between zenith and horizon. Move with `zenithColor` or the gradient bands.              |
| `horizonColor`     | `#dff0ff`   | `#f4fbff`     | Horizon on the sun side, warmed by the sun term. Whiten for haze, saturate for crisp air.             |
| `horizonAwayColor` | `#cfe4f8`   | `#e6f3fc`     | Horizon opposite the sun. Keep slightly cooler/darker than `horizonColor` or the sky loses direction. |
| `hazeColor`        | `#e4f1fd`   | `#eef7fd`     | Below-horizon haze — and the linear fog color (see pitfalls).                                         |
| `sunDiscColor`     | `#fffef8`   | `#ffffff`     | Sun disc tint. Warm it for golden hour, whiten for noon.                                              |
| `sunHaloColor`     | `#fff3d4`   | `#fff6e2`     | Tight halo around the disc. The main "warm sun" cue.                                                  |
| `cloudLitColor`    | `#ffffff`   | `#ffffff`     | Sunlit cloud faces.                                                                                   |
| `cloudShadeColor`  | `#b6c6dd`   | `#c2d2e4`     | Cloud shade. Darker = more dramatic clouds; lighter = cotton heaven.                                  |

For a bluer, clearer sky: deepen `zenithColor` and `midColor`, pull the two
horizon colors slightly toward blue, lighten `cloudShadeColor`. For a softer,
dreamier sky: move everything toward white, starting at the horizon.

## Sun visual parameters

These shape the sun inside the dome. `discInnerCos`/`discOuterCos` set the
angular disc size (cosines of the core and rim half-angles — shipped at
`0.99972`/`0.99912`, about 1.35°/2.4°, identical in both zones; closer to 1
means a smaller disc). The disc and halo feed the emissive attachment, so
they are what the selective bloom pass actually sees.

| Parameter       | Coral Bloom | Cloud Terrace | Visual effect                                                                             |
| --------------- | ----------- | ------------- | ----------------------------------------------------------------------------------------- |
| `discIntensity` | 4.2         | 3.8           | HDR disc brightness; the strongest bloom feed. Lower for a calmer sun, raise for sparkle. |
| `haloIntensity` | 2.1         | 1.6           | Tight golden aureole (also HDR, blooms softly).                                           |
| `glowIntensity` | 0.55        | 0.6           | Wide warm glow, beauty pass only (below bloom threshold).                                 |
| `washIntensity` | 0.2         | 0.22          | Very wide warm tint across the sun-side sky.                                              |

## Resolved lighting, fog, and shadows

These fields of the `resolve*Sky` return value drive the actual light rig —
the only numbers that change how bright surfaces are.

| Parameter               | Coral Bloom               | Cloud Terrace | Visual effect                                                                                                       |
| ----------------------- | ------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------- |
| `sunColor`              | `#fff2dc`                 | `#fff6e8`     | Directional light tint. Warmer = later afternoon.                                                                   |
| `sunIntensity`          | 1                         | 1.5           | Directional light strength: direct sun, highlights, and shadow contrast. The modelling knob.                        |
| `hemisphereSkyColor`    | `#93c4f2`                 | `#93c4f2`     | Ambient light from above. Bluer = cooler shadows.                                                                   |
| `hemisphereGroundColor` | `#c0a075`                 | `#c0a075`     | Ambient bounce from below. Warmer/darker soil tone warms undersides.                                                |
| `hemisphereIntensity`   | 2                         | 3             | Ambient fill strength: how readable everything is from every angle.                                                 |
| `skyBrightness`         | 1.42                      | 1.55          | HDR multiplier on the dome gradient only — lights nothing, but sets how luminous the sky itself reads through ACES. |
| `fogNear` / `fogFar`    | 96 / 260                  | 110 / 300     | Where the vista haze starts and closes, in metres. Shorter `fogFar` = more intimate, mistier world.                 |
| `shadowExtent`          | 92                        | 104           | Orthographic shadow half-extent; must cover the zone disc (test-pinned).                                            |
| `shadowMapSize`         | 1024 (2048 on `enhanced`) | same          | Shadow resolution; leave alone for mood work.                                                                       |

## Cloud budget

The `clouds` object appears **twice** per sky model — once for the
`compatible` branch, once for the others — so edit both or the tiers
disagree. Current values are identical across branches within each zone.

| Parameter    | Coral Bloom                    | Cloud Terrace | Visual effect                                                         |
| ------------ | ------------------------------ | ------------- | --------------------------------------------------------------------- |
| `octaves`    | 5 (6 enhanced, 3 compatible)   | same          | FBM detail; a real fragment-cost knob, not a mood knob.               |
| `detail`     | `true` (`false` on compatible) | same          | Billow detail layer on/off.                                           |
| `coverage`   | 0.5                            | 0.62          | How much sky is clouded. The clearest "weather" dial.                 |
| `scale`      | 0.5                            | 0.5           | Cloud feature size. Lower = larger, softer masses.                    |
| `speed`      | 0.008                          | 0.008         | Drift speed on the TSL clock.                                         |
| `brightness` | 1.8                            | 1.9           | HDR multiplier on the sunlit cloud color; feeds bloom at high values. |

## The ratio is the mood

Surface brightness is roughly `material color × (directional sun +
hemisphere ambient)`. Neither number means anything alone — the ratio
between them does:

- **Harsh-summer look**: high `sunIntensity`, low `hemisphereIntensity` —
  deep shadows, strong modelling, squinting brightness on lit faces.
- **Soft-bright look**: lower `sunIntensity`, high `hemisphereIntensity` —
  shallow shadows, everything readable, slight flatness as the trade-off.

Cloud Terrace currently ships softer and brighter than Coral Bloom exactly
through this ratio (sun 1.5 / hemisphere 3 versus sun 1 / hemisphere 2 — the
terrace's fill light absolutely dominates). The flora materials are lit
`MeshStandardNodeMaterial` graphs with no emissive term, so a ground flower
seen against the sun is lit almost entirely by the hemisphere fill: if
flowers read as dark confetti from some angles, raise ambient, not sun.

## Recipe: a sunnier grove that stays readable

A concrete before/after on Coral Bloom — more sun modelling and sparkle
without losing the bright, readable meadow. In
`coral-bloom-sky-model.ts`:

```ts
sunColor: "#fff2dc",
sunIntensity: 1,
```

```ts
sunColor: "#fff2dc",
sunIntensity: 1.7,
```

```ts
hemisphereSkyColor: "#93c4f2",
hemisphereGroundColor: "#c0a075",
hemisphereIntensity: 2,
```

```ts
hemisphereSkyColor: "#93c4f2",
hemisphereGroundColor: "#c0a075",
hemisphereIntensity: 1.7,
```

And in `environments/park/src/zones/registry.ts`:

```ts
art: Object.freeze({
  resolveSky: resolveCoralBloomSky,
  post: Object.freeze({ bloom: Object.freeze({ strength: 0.62 }) }),
}),
```

```ts
art: Object.freeze({
  resolveSky: resolveCoralBloomSky,
  post: Object.freeze({ bloom: Object.freeze({ strength: 0.55 }), exposure: 1.12 }),
}),
```

Rationale: sun up from 1 to 1.7 brings back shadow shape and canopy sparkle;
the hemisphere steps down from 2 to 1.7 so the fill no longer flattens the
modelling the sun just added; exposure 1.12 lifts the whole frame back to
the original perceived brightness; bloom eases off so the stronger sun
glints do not halo. On these two zones a `sunIntensity` between roughly 1.2
and 1.8 is the sweet range — below that shadows wash out, above that the
shared late-afternoon sun starts to fight the fresh daytime palettes. For
Cloud Terrace the same move works from its 1.5 baseline, but keep the
hemisphere dominant (it is what makes the terrace read as heaven).

## Flower readability levers

Once the light is right, ground-flora contrast is tuned at the material
level, all inside `environments/park/src/scenes/coral-bloom/`:

- **Terrain petal-blush wash** — `CoralBloomTerrain.tsx`. The pink stain
  under the grove competes with the flowers. Lower it for contrast:

```ts
const blushed = mix(crest, petalBlush, blushMask.mul(0.32));
```

```ts
const blushed = mix(crest, petalBlush, blushMask.mul(0.15));
```

- **Per-instance flower brightness** — `CoralBloomFlora.tsx`
  (`FlowerInstances`). The `0.85` floor sets how bright the dimmest flower
  is; raise it to lift every head (the drift-petal system at `0.85 + 0.3 *
petal.tint` responds the same way):

```ts
mesh.setColorAt(index, color.copy(hue).multiplyScalar(0.85 + 0.3 * flower.tint));
```

```ts
mesh.setColorAt(index, color.copy(hue).multiplyScalar(1.0 + 0.3 * flower.tint));
```

- **Petal palette saturation** — `CORAL_PETAL_PALETTE` in
  `coral-bloom-model.ts` feeds both flowers and drift petals; desaturate for
  pastel, saturate for festival.
- **Density and head size** — the flower counts per tier and
  `headScale: 0.75 + random() * 0.6` in `coral-bloom-model.ts`. Bigger heads
  read better at distance; counts move render budgets, so prefer scale.

## Experiment protocol

Work in this order; each step is one save-and-look cycle:

1. Screenshot the untouched baseline (spawn vista plus one against-the-sun
   view) so "better" stays honest.
2. Move `sunIntensity` first — it defines the modelling everything else
   reacts to.
3. Move `hemisphereIntensity` to set shadow depth and flower readability.
4. Add or tune `exposure` in the registry for global brightness.
5. Adjust `fogFar` (and `fogNear` with it) for depth and intimacy.
6. Fine-tune colors last: palette, hemisphere colors, material tints.
7. Verify on both backends (with and without
   `?al-amr-rendering=force-webgl2`) and remember what `compatible` strips:
   bloom, cloud detail, flora shadows. If you ship on `compatible`, grade on
   `compatible`.

## Pitfalls and invariants

- `fogColor` must always equal the palette's `hazeColor` — the fog is the
  dome's below-horizon haze, and any mismatch seams the world rim against
  the sky. Edit the palette, not the fog line.
- `clouds.coverage` (and its siblings) appears twice per sky model — the
  `compatible` branch and the others. Change both or the tiers show
  different weather.
- Keep `fogNear < fogFar`, and keep `shadowExtent` at or above the zone
  radius: the `*_SHADOW_COVERS_*` guardrail constants are pinned by sky
  tests and will fail the suite.
- Color and light changes break no tests and no render budgets. Adding
  meshes, instances, or instance attributes does — budgets are measured per
  zone per lane, so stay on the value-tuning side unless you mean to re-budget.
- Cloud `octaves` and `coverage` change real fragment cost per tier. Judge
  performance on the tier you ship, not on `enhanced`.
- Post overrides are uniform mutations, so they are cheap to flip — but they
  apply per zone swap, and an out-of-range value is silently clamped
  (`bloom.strength` to `[0, 8]`, `exposure` to `[0.1, 8]`). If an extreme
  edit "does nothing", you are probably sitting on a clamp.

For the architecture behind every one of these knobs — the dome, the post
pipeline, the tier policy — see
[Park zones 3D techniques](./park-zones-3d-techniques.md).
