Source: https://doc.babylonjs.com/lite/architecture/mesh-opacity

# Module: Mesh opacity

> Package path: `packages/babylon-lite/src/mesh/opacity/`

## Purpose

Provide opt-in native per-mesh opacity for ordinary Standard and PBR meshes without
changing or cloning their materials. The compat layer implements `Mesh.visibility`
automatically through the same native API. Meshes sharing a material, including
glTF primitive meshes, can have independent opacity.

Opacity is separate from mesh visibility, hierarchy, picking, bounds and visibility
epochs. Thin instances share their owning mesh's opacity. Node/shader materials
cannot fade below 1. MeshLoD instances are not ordinary Mesh objects and are not
supported targets at any opacity. Per-instance opacity, submesh opacity and compat
`InstancedMesh.visibility` are outside this module's contract.

## Public API surface

Both functions are named exports from the sole package root:

```typescript
function setMeshOpacity(mesh: Mesh, opacity: number): void;
function getMeshOpacity(mesh: Mesh): number;
```

The default is 1. The native setter clamps finite numbers to `[0, 1]`; the getter
returns the stored clamped value, including after disposal.

The setter performs these operations in order:

1. Reject non-number, NaN and infinite input with `RangeError`:
   `setMeshOpacity: opacity must be a finite number, got <value>`.
2. Require an object with the existing `_gpu` Mesh discriminator. Otherwise throw:
   `setMeshOpacity: "<name>" is not a Lite Mesh (for example a MeshLoD instance); per-mesh opacity is not supported on MeshLoD in this release.`
   The getter performs the same target check with its own API-name prefix.
3. Reject `_disposed`:
   `Mesh "<name>" cannot set opacity: it was disposed when it left its last scene. Create a new mesh instead.`
4. Clamp the value.
5. If below 1, validate a non-null material's `_buildGroup._materialFamily`.
   Only `"standard"` and `"pbr"` are accepted; otherwise throw:
   `setMeshOpacity: mesh "<name>" uses a "<family>" material; mesh opacity supports only "standard" and "pbr" materials.`
6. Install the feature once. If the stored/default value already equals the new
   clamped value, return without storing, dirtying or writing.
7. Store the value and synchronously set `mesh._skipColorDraw = value === 0`,
   enqueue a rebuild on crossing 1, and update live binding uniforms with every
   changed value, including 1.

A null material is permitted. An ordinary, non-disposed Mesh can reset to 1
regardless of material family. Invalid calls must not install hooks/extensions,
store state, allocate/write buffers, enqueue swaps or mutate scenes.

## Internal architecture

### Mesh value and binding records

The feature uses a local intersection, not a member on the core Mesh interface:

```typescript
type OpacityMesh = Mesh & { _opacity?: number };
interface OpacityBinding {
    readonly _buffer: GPUBuffer;
    readonly _device: GPUDevice;
    readonly _engine: EngineContext;
    readonly _disposers: (() => void)[];
}
```

`mesh-opacity.ts` owns validation, the scalar, installation, transitions and GPU
records. A lazily created `WeakMap<Mesh, Set<OpacityBinding>>` tracks live
binding-owned buffers. Neither the map nor its sets are allocated at import time.
A reusable one-element `Float32Array` supplies live writes; a separate two-element
array supplies binding initialization.

Each mesh's binding lifetime and device pair owns one 16-byte uniform buffer.
Its first two `f32` values are `[opacity, policy]`, at byte offsets 0 and 4.
Standard ignores the second value (initialized to 0); PBR uses it as an immutable
per-binding OPAQUE policy. Live setters write only four bytes at offset 0, never
the policy. Repeated binds with the same
disposer-array identity and device reuse the existing buffer and record without
another allocation, initial upload or disposer, preserving its creation-time
policy even if a later bind supplies a different policy. Different lifetime arrays or
devices create separate records; no material owns this buffer. Native shallow
mesh clones copy the scalar and its corresponding skip flag but never the records.

### Lazy installation and chained installers

The first successful setter call runs this once-guarded sequence:

1. Register Standard extension `"0-mesh-opacity"`.
2. Register PBR extension `"z-mesh-opacity"`.
3. Install a mesh-feature encoder returning `1 << 22` iff opacity is below 1.
4. Install a PBR binding-context augmenter that copies the binding's disposer
   array into `context._disposers`.
5. Install material-transition validation using the mesh's live opacity.

Extension registration invalidates sorted-extension caches. No registration,
map/set allocation or GPU work executes merely by importing the API.

The generic installers preserve earlier features in installation order:

| Installer                       | Composition                                                                                            |
| ------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `_installMeshFeatureExtra`      | OR the previous encoder's result with the new encoder's result.                                        |
| `_installPbrBindingContext`     | Invoke the previous augmenter, then the new augmenter, with the same context, scalar id and disposers. |
| `_installMeshMaterialValidator` | Run the previous validator, then the new validator, before mutation.                                   |

This preserves mirrored-mesh encoding, rect-area binding context and indexed-indirect
borrow validation in either install order. Features installing into a chained slot
must guard their own one-time installation: opacity does so, and
`installRectAreaEmissionPbr` does so before installing its lighting-id/disposer
augmenter. Binding ids and disposer arrays remain per invocation, not captured from
the first prepare.

### Mesh-bit transport and Standard folding

`material/mesh-features.ts` reserves mesh bit 22 with a comment. The opacity
encoder owns its meaning; it does not add a shared opacity constant or read opacity
from core code. The installed encoder returns 0 at opacity 1 and sets bit 22
below 1. It does not encode material alpha policy.

PBR's opacity fragment declares the feature-local
`MESH_OPACITY_MESH_BIT = 1 << 22`. OPAQUE alpha policy lives in the uniform,
not a shader-variant bit.

Standard uses its existing extension hook:

```typescript
_meshFeatures?(meshFeatures: number, material?: StandardMaterialProps): number;
```

The opacity Standard hook tests mesh bit 22 and the source material's cached
`NO_COLOR_OUTPUT | ESM_SHADOW_OUTPUT` bits. For a fading colour mesh it returns
the feature-local `STD_MESH_OPACITY_BIT = 1 << 30` plus
`MATERIAL_ALPHA_BLEND`; otherwise it returns 0. Bit 30 is reserved by a comment
in `standard-flags.ts`. Both Standard forward and geometry fold this result into
their effective material features.

Standard forward queue classification retains the live `mat.alpha < 1` term and
tests the final `HAS_OPACITY_TEXTURE | MATERIAL_ALPHA_BLEND` word. Queue and
pipeline selection therefore use the opacity-folded blend bit without dropping
the existing live material-alpha behavior.

### PBR folding at three consumers

PBR has no per-mesh extension fold hook or scratch object. The existing generic
transport is:

```typescript
(meshFeatures >>> 16) & PBR_HAS_ALPHA_BLEND;
```

Mesh bit 22 maps to material alpha-blend bit 6. The feature bit remains in the
mesh word for the opacity PBR fragment; the generic fold supplies routing, blend
state and compose-key identity.

The three consumers are:

1. **`pbr-renderable.ts` forward rebuild.** OR the transport into
   `renderFeatures.features` only when `features2` contains neither
   `PBR2_NO_COLOR_OUTPUT` nor `PBR2_ESM_SHADOW_OUTPUT`. This effective word drives
   composition, binding/pipeline keys and transparent classification.
2. **`pbr-geometry-renderable.ts` geometry pipeline selection.** Geometry's
   composition view strips the material blend branch, but its source blend
   decision ORs the transport into `source._renderFeatures?.features ?? 0`.
   This colour-output route therefore blends and disables depth writes for a
   fading mesh while the geometry shader still gates on effective alpha.
3. **`pbr-built-variant.ts` composed-variant lookup.** Before `_pbrComposeKey`,
   fold the same transport into the source features with the same
   no-colour/ESM mask as forward. The lookup must ask for the variant the forward
   rebuild actually composes.

Shadow/no-colour/ESM outputs do not gain the alpha-blend bit or opacity fragment.
Mesh bit 22 may still distinguish otherwise identical shadow variant keys; it
does not change caster alpha.

`_pbrMeshRequest(scene, mesh, noShadows)` retains its four-item return:

```typescript
readonly[(context, meshFeatures, lightMode, singleLightType)];
```

Forward generation metadata remains:

```typescript
[geometryContext, meshFeatures, lightMode, singleLightType, renderFeatures];
```

There is no additional folded-material field in either tuple and no change to
`Renderable` or geometry-view generation metadata.

### Binding context and retirement transport

`_PbrBindCtx._mesh` permits `Mesh | null`. The opacity extension requires an
actual mesh and its binding-owned disposer array.

`_installPbrBindingContext` also enables `_pbrBindingIdentity`, an identity
function for the optional numeric binding id, and `_createPbrBindingDisposers`,
a factory returning a fresh array. `createPbrMeshBindGroup` builds its ordinary
context, invokes the installed augmenters, then runs the fragment bind hooks.
Only feature-owned augmenters populate `_disposers` and `_lightingId`.

PBR geometry uses exactly:

```typescript
const bindingDisposers = _createPbrBindingDisposers?.();
```

There is no `?? []` fallback. Without an installer this remains undefined and
does not allocate an auxiliary lifetime collection. With opacity installed,
the factory exists before geometry binds. The same array reaches the PBR context
and geometry teardown. Standard passes its existing main/geometry binding-owned
array through `StdExt._bind`.

### Transparent and geometry loop skip

The Mesh interface declares an optional `@internal _skipColorDraw?: boolean`.
It has no factory initializer and is absent from public declarations. Only
`setMeshOpacity` writes it, immediately after storing the clamped scalar. It is
true exactly at opacity 0 and false at every other assigned value. It lives with
the mesh and owns no resources, so no additional disposal hook is needed.

The general render loops read this generic flag alongside their existing
`visible === false` test. For this feature, a plain internal-field read is an
explicitly approved exception to GUIDANCE's engine-installed-seam requirement:
it costs about 16–25 B in scenes that never use opacity, less than the
alternative opt-in per-renderable draw-wrapper hook, which measured up to 148 B
in the same scenes. No renderer imports opacity or reads the opacity scalar.

| Loop                                      | Skip condition                                                               | Routing                                                                                                                                   |
| ----------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `render-task-base.ts` `drawList`          | `mesh && (mesh.visible === false \|\| (transparent && mesh._skipColorDraw))` | Fourth optional boolean is true only for the transparent list and transmission overlay. Opaque bundle recording and direct lists omit it. |
| `transmission.ts` transparent loop        | `mesh && (mesh.visible === false \|\| mesh._skipColorDraw)`                  | Checked at the existing position, after any transmissive texture update. The renderOnTop overlay uses `drawList(..., true)`.              |
| `geometry-renderer-task.ts` geometry loop | `mesh.visible === false \|\| mesh._skipColorDraw`                            | Geometry bindings are colour/output bindings, never shadow casters.                                                                       |

Fading Standard/PBR colour bindings are forced into transparent lists by the
existing feature folding. Shadow renderables are never transparent, so their
opaque/direct lists ignore the skip flag and preserve full-strength casting.
Opaque render-bundle recording and visibility epochs are unchanged.

The loops read the flag every frame. A `0.5 → 0 → 0.5` transition skips and resumes
without rebuilding or caching a stale skip. Opaque twins replay the same bundle.
Public `visible`, compat `isVisible`, picking and accessibility are independent
and never changed by opacity.

This is CPU draw omission, not shader discard. Standard/PBR builders return
their ordinary unwrapped draws; there is no mesh draw-hook module, wrapper or
installer. Direct invocation of a raw binding bypasses task-level filtering.
The diagnostic overdraw probe remains unchanged and may count opacity-0
transparent bindings.

## Pipeline configuration

| Route                                  | Opacity 1                 | Opacity in (0, 1)                                      | Opacity 0            |
| -------------------------------------- | ------------------------- | ------------------------------------------------------ | -------------------- |
| Standard/PBR main and offscreen colour | Existing variant/bindings | Blend on, depth writes off, sorted transparent queue   | No draw              |
| PBR refraction                         | Existing variant          | Same per-mesh factor; texture-group policy unchanged   | No draw              |
| Standard/PBR geometry output           | Existing variant          | Blend on, depth writes off; alpha reaches `> 0.4` gate | No draw/outputs      |
| No-colour/CSM/PCF/ESM shadows          | Unchanged                 | Full-strength caster; no opacity fragment/binding      | Full-strength caster |
| Picking, culling, bounds and `visible` | Unchanged                 | Unchanged                                              | Unchanged            |
| Transmission opaque capture/GPS depth  | Existing classification   | Transparent, excluded from opaque/depth capture        | Transparent, no draw |

Fading a mesh in a geometry task makes it a blended participant. Every colour
target must be blendable: default `VIEW_DEPTH` (`r32float`) requires the device
feature `float32-blendable`, or an `r16float` format override. Integer tag targets
are never blended. This is the same requirement as a material's own BLEND policy.
PBR, Standard, and Node geometry pipeline creation enforces the format contract
through `_geometryColorTarget`. A blended 32-bit float target without the feature
throws the existing error:
`Transparent geometry output format "${format}" requires the float32-blendable WebGPU feature or a blendable format override.`
Validation also rejects blended integer formats; the mesh-blending tag target
remains explicitly unblended.

Faded cutouts discard geometry fragments whose post-opacity `alpha <= 0.4`,
before any data or mesh-blending tag write. They therefore preserve underlying
tags rather than writing a cutout's raw tag through a suppressed data sample.
The trade-off is that these samples also stop contributing to an extra
emit-colour slot. Forward cutout coverage is unchanged.

PBR main transparent order is 150; Standard main and both geometry routes use 200. Existing material-level alpha-to-coverage and `:a2c` pipeline identity
remain; A2C and blending both see final alpha. No material alpha, alpha-test cutoff,
alphaBlend flag or visible flag is mutated. Mirroring, VAT, thin instances and
transmission remain independent.

### Uniform layout

Each active opacity fragment contributes one structured declaration:

```typescript
{
    _name: "meshOpacity",
    _type: { _kind: "uniform-buffer" },
    _visibility: SS.FRAGMENT,
}
```

Standard's helper is `struct meshOpacityUniforms{value:f32}`; PBR's is
`struct meshOpacityUniforms{value:f32,policy:f32}`. The composer assigns the
group-1 binding index from declarations; never parse emitted WGSL for an index.
The bind hook pushes `{ binding, resource: { buffer } }` and returns
`binding + 1`. It reuses a record with the same lifetime array and device when
present; otherwise it initializes the uniform from current mesh state, records
the buffer and appends its disposer. Missing mesh/scene or lifetime transport throws
a route-specific internal invariant error rather than silently allocating a
fallback collection.

`_bindMeshOpacity` accepts a final optional numeric `policy = 0` argument.
Only creation initializes both floats; a reused record never rewrites them.
PBR chooses the classifier with the same post-fold
`ctx._features & PBR_HAS_ALPHA_BLEND` discriminator as its fragment slots.
Forward bindings use the **rendered binding material's own `_renderFeatures`**:
a public material view's declared flags, not its source's flags. Otherwise,
geometry bindings use `(material.source ?? material)._renderFeatures`, because
the geometry view strips the blend branch. Set policy 1 iff the chosen features
exist and their pre-fold `features` contain neither `PBR_HAS_ALPHA_BLEND` nor
`PBR_HAS_ALPHA_TEST`; otherwise set 0, including when features are absent.
This uses only a type-only material-view import and plain fields, adding no
runtime module dependency to Standard-only opacity scenes.

Overrides therefore use the override's own policy, not `mesh.material`.
Material swaps and renderable rebuilds follow the new material/view policy through
new lifetime-owned bindings. Rebinding the same lifetime/device retains its
creation-time policy, consistent with its already-prepared pipeline variant.

Standard's leading `"0-mesh-opacity"` extension/fragment id aligns registry and
composer ordering before `"0-std-uv-transform"`. PBR uses
`id: "z-mesh-opacity", phase: "fragment"`; its bind order follows the composed
fragment key. Main, refraction and geometry each use the layout they composed.

## Shader logic

### Standard

Use slot `BC`:

```wgsl
alpha = alpha * meshOpacity.value;
color = vec4<f32>(color.rgb, color.a * meshOpacity.value);
```

The original `AT` alpha-test discard has already run; fog `BA` has not.
Geometry compositions (`features & GEOMETRY_OUTPUT`) append
`if (mat.aCut > 0.0 && alpha <= 0.4) { discard; }` to this `BC`. `mat` is in scope,
and `alpha` is the same running value used by the geometry data gate. Forward
`BC` remains exactly the two statements above.
Colour returns `color.a`, while geometry gates on `alpha > 0.4`, so both alpha
representations must be multiplied. No-colour/ESM templates return without this
fragment. OPAQUE and surviving MASK fragments can therefore fade without
changing their original discard threshold.

### PBR

Return no fragment unless mesh bit 22 is set and features2 is neither no-colour
nor ESM. Forward composition includes both policy normalization and the final
opacity multiply; geometry combines them in one slot:

| Composed features                   | Slot | Statement                                                                   |
| ----------------------------------- | ---- | --------------------------------------------------------------------------- |
| `PBR_HAS_ALPHA_BLEND` set (forward) | `BC` | `if (meshOpacity.policy > 0.5) { alpha = 1.0; }`                            |
| Same forward composition            | `FA` | `finalAlpha = finalAlpha * meshOpacity.value;`                              |
| Geometry with blend branch stripped | `BC` | `alpha = select(alpha, 1.0, meshOpacity.policy > 0.5) * meshOpacity.value;` |

**OPAQUE alpha policy:** policy 1 treats source alpha as 1 even if the
base-colour texture, base-colour factor or vertex colours carry alpha. Forward
`BC` runs after decoding/vertex-colour alpha and before `var finalAlpha=`.
The usual luminance fold saturates normalized alpha to 1; `FA` multiplies
**after** that fold. Thus final alpha equals mesh opacity, not opacity plus
luminance. Geometry normalizes and multiplies together before `alpha > 0.4`.
Gamma-albedo and unlit paths declare `alpha` and use the same policy.

**Own alpha policies:** BLEND and material alpha below 1 retain the existing
forward `FA` multiply after the normal output-alpha calculation:
`saturate(alpha * materialAlpha + luminanceOverAlpha²) * opacity`. Geometry
strips the blend branch and uses the combined `BC` with policy 0. MASK also
uses policy 0 and keeps its forward `FA` / geometry `BC` behavior. Both occur **after** the original
`AT` cutoff, so that cutoff tests the material's own alpha, not mesh opacity.
Forward mesh opacity does not change which MASK fragments survive.
In geometry only, PBR variants with `PBR_HAS_ALPHA_TEST` append
`if (alpha <= 0.4) { discard; }` after the combined opacity `BC`, before the first
data/tag write. Thus faded cutouts also obey the geometry data gate, while
opacity-1 variants keep their original raw MASK tag and Standard aCut select.
Non-cutout geometry and forward paths do not gain this discard. The opacity
extension intentionally does not import geometry attachment state; a rejected
sample also omits any extra emit-colour output.

The shadow-only fragment owns and internally exports
`PBR2_HAS_SHADOW_ONLY = 1 << 30`. The lazy opacity fragment imports it from
`fragments/shadow-only-fragment.ts`, not shared flags or the public root. When
that features2 bit is set, declare dependency `["shadow-only"]`; otherwise omit
the dependency. Shadow-only's own features force alpha blending, so its policy
is 0. The policy `BC` runs after shadow-only's `BC` and leaves that mask intact;
opacity `FA` runs after shadow-only's final-alpha write and scales the mask,
without a missing-dependency error or luminance bleed.

Refraction opens its conditional in `NI` and closes it in `BC`. The `"z-"` id
places the geometry alpha override or multiplier after that closing brace.
Transmission/refraction write colour, not alpha. The selected opacity slot
runs before output or the geometry gate.

**Why not a variant bit?** The blend fold makes faded OPAQUE and BLEND materials
share a compose key when their other features match. A distinguishing key bit
would require shared transport/key changes and add bytes to unrelated scenes.
Policy derived from cached mesh/material features can also go stale or describe
the wrong material for an override. The binding uniform follows the rendered
material/view classifier and its lifetime instead; no shared shader-key or render-loop change is
needed.

## State machine and lifecycle

| Transition          | Dirtying                                         | Existing uniform writes                       | Buffer lifecycle                     |
| ------------------- | ------------------------------------------------ | --------------------------------------------- | ------------------------------------ |
| 1 → 0.6             | Once per crossing; enqueue in every owning scene | Latest factor to any still-live records       | New lifetime allocates               |
| 0.6 → 0.4 → 0 → 0.3 | None                                             | Four bytes per live binding per changed value | No setter allocation/destruction     |
| 0.3 → 1             | Once per crossing; enqueue in every owning scene | Write 1 to any still-live records             | Old buffers retire with old bindings |
| Equal clamped value | None                                             | None                                          | None                                 |
| Unregistered mesh   | No scene enqueue                                 | No records unless already bound               | First bind reads stored value        |

Dirtying uses `markMeshRenderableDirty` and existing per-scene swap-queue
deduplication; it never bumps the visibility epoch. Boundary enqueueing and
direct writes are independent.

### Explicit render tasks

An `autoMirror: false` render task normally retains the renderables resolved by
`addMeshToTask`. A crossing of 1 changes the opaque/transparent pipeline variant
only when the task opts into `enableRenderTaskMeshRefresh` before recording,
and the entry is a scene-owned mesh added **without** a per-task material
override. As with material swaps, the owning scene first drains its rebuild
queue; the enabled task follows the resulting renderable version on execute.
It rebuilds its own auxiliary renderable and retires the old lifetime disposers
after submitted GPU work drains. See
[Frame Graph — explicit-task mesh refresh](https://doc.babylonjs.com/lite/architecture/28-frame-graph).

```typescript
const task = createRenderTask({ name: "fading", rt, autoMirror: false }, engine, scene);
enableRenderTaskMeshRefresh(task);
addMeshToTask(task, mesh);
```

Once a transparent binding is current, updates below 1 and the 0 skip are live;
they require no task rebuild. A plain explicit task retains its old renderable
across crossings, so callers must deliberately synchronize/re-record it if they
do not enable refresh. Opaque lists do not check the opacity-0 flag: those lists
also serve shadow tasks, where opacity must not remove casters, and opaque
render bundles cache draws. Skipping there would break the shadow gate and
cached-bundle replay contract. The flag is read only on transparent/transmission
colour lists and geometry paths.

### Unregistered meshes and retained bindings

A mesh registered in no scene but drawn through an explicit geometry-task
`meshes` list picks up opacity threshold crossings (`1 ↔ <1`, selecting a
pipeline/blend variant) only at that task's next renderable sync. This is the same
contract as material/topology changes on such meshes; the setter does not
invalidate off-scene geometry tasks. Value updates below 1 and the opacity-0 skip
apply immediately. Returning to 1 also writes 1 to retained binding buffers, so
they do not retain the old opacity while waiting for that sync.

**Rapid double crossing:** for `0.3 → 1 → 0.6` before asynchronous replacement
settles, the retained transparent binding must receive 0.6, even though a rebuild
was enqueued. A replacement binding initializes from the latest stored value.
The intermediate transition to 1 writes 1 to still-live buffers. Zero/fraction
changes require no rebuild because the loops read the synchronously updated skip flag.

For each direct write, require
`record._engine._device === record._device`. Skip stale-device records without
destroying them in the setter. The binding disposer removes its record before
destroying the buffer and is idempotent.

Refraction rebinds reuse the opacity record and buffer associated with the same
renderable lifetime array and device, even when other bind-group entries change.
Each new lifetime or device gets a separate buffer. There is no new signature
cache. Teardown of one scene removes only that scene's records when a mesh belongs
to several scenes.
The existing last-scene removal disposes mesh GPU resources and marks it
disposed; opacity adds no mesh-disposal or retirement observer.

Recovery's material-group rebuild computes mesh features even at opacity 1.
The installed opacity feature encoder, when the binding map exists, retires
records whose engine's current device differs from their captured device.
The setter uses the same helper before writing live records. Retirement removes
the record before destroying its lost-device buffer; a later old disposer sees
that deletion already happened and does nothing. Recovery clears the scene's
disposer map without invoking it, so encoder retirement is required even if
opacity was reset to 1 and no replacement binding is created. Faded meshes
rebuild bindings from the latest scalar; live updates reach only matched-device
records. Opacity owns no device-scoped cache.

### Material replacement and target exclusion

For a faded registered mesh, the chained validator rejects an unsupported
material synchronously before `mesh.material` stores the value or queues work.
Registration validates an unregistered mesh's pending raw assignment before
enrollment. Previous validators retain their ordering and atomicity.

MeshLoD instances lack `_gpu` and cannot be passed to either opacity API.
Their asset/instance enrollment paths do not accept an ordinary Mesh, so neither
operation order puts an opacity-bearing Mesh into a MeshLoD batch. No MeshLoD
hook, scan or metadata is added. Sharing a material with a fading ordinary Mesh
does not mutate that material.

## Babylon.js equivalence map

Compat `Mesh.visibility` statically imports the native API; there is no compat
enabler. The wrapper stores the raw finite value but renders the clamped native
value. Non-finite assignments throw. Family checks use the compat
`unsupported(...)` helper.

Validate all owned primitives before writing any; publish the wrapper's raw value
only after native writes succeed. A GPU wrapper owns itself. A transform-only
glTF-node wrapper owns its immediate GPU primitive children, not descendants
through child nodes. The synthetic root owns no geometry. `_fromLite` reads
native opacity for an actual GPU wrapper; transform-only wrappers start at 1.

Raw getters belong to the wrapper assigned, not to an inherited parent factor.
Owner fan-out changes primitive rendering factors without changing primitive
wrappers' independent raw getters. Effective native opacity is also checked for
material replacement. Pending NodeMaterial handles are rejected below 1 before
their native material exists. Clone copies the raw value without a redundant
setter. `isVisible` and `_syncVisibility` are unchanged.

| Behavior                                                         | Equivalence/deviation                                      |
| ---------------------------------------------------------------- | ---------------------------------------------------------- |
| Programmatic Standard/PBR with no explicit BJS transparency mode | Visibility/blend parity                                    |
| Native glTF OPAQUE fading                                        | Source alpha normalized to 1; final alpha equals opacity   |
| MASK / shadow-only fading                                        | Preserve original cutoff / shadow mask, then scale opacity |
| Finite out-of-range values                                       | Raw compat getter, clamped rendering                       |
| NaN/Infinity                                                     | RangeError deviation                                       |
| Node/shader materials below 1                                    | Explicit unsupported error                                 |
| Shadows                                                          | Full strength even at zero                                 |
| Picking, hierarchy and clones                                    | Picking unchanged, no inheritance, scalar copied           |
| InstancedMesh/submeshes                                          | No new support                                             |

## Dependencies

Use the existing Standard/PBR registries, structured shader composer, material
family tags, scene validator/dirty registry, binding-owned lifetime arrays and
clone/recovery paths. Runtime family code imports only generic seams, never this
feature. Internal hooks and WebGPU handles do not leak through root declarations.
The package still exposes one root export.

Core transport and draw call sites can survive across production chunk boundaries
even when feature bodies are absent. Absence of opacity implementation markers is
not proof of zero runtime-byte cost. Runtime-size checks count actually fetched JS
with the repository's existing exclusions and gzip accounting, not artifact
inventories.

## Test specification

- Native input/error atomicity, finite clamping, disposal, clone isolation,
  unchanged material/picking/visibility, MeshLoD rejection and null-material policy.
- All boundary/non-boundary transition counts, multi-scene records, idempotent
  disposal, recovery/device mismatch and rapid-double-crossing buffer freshness.
- Off-scene retained geometry bindings receive 1 on reset. Repeated binds on the
  same device and lifetime allocate one buffer/disposer/record and write once per
  update; different lifetime arrays or devices remain independent.
- Atomic unsupported material swaps and both installation orders with the real
  indexed-indirect borrow validator; previous-first feature/context chains.
- PBR per-binding OPAQUE policy with alpha-bearing texture/factor/vertex colours,
  gamma/unlit forward/geometry matrix, large luminance and coherent compose keys.
  Explicit overrides in both directions use the rendered binding's policy:
  forward public views use their own flags when adding or stripping blend,
  while geometry views use the source's flags. Plain OPAQUE forward bindings
  retain policy 1; OPAQUE/BLEND/OPAQUE/Standard swaps create new bindings.
  Live writes preserve the policy while changing only four bytes at offset 0;
  same-lifetime reuse preserves creation-time policy. Shadow-only initializes
  `[opacity, 0]` and retains its mask.
  BLEND/material-alpha multiplication and MASK forward `FA` / geometry `BC`
  remain after the original cutoff. Standard output alpha ignores diffuse-texture
  alpha. Verify shadow-only dependencies, refraction closing braces, geometry
  `> 0.4` gates and composer/bind-index equality.
- Transparent and geometry task loops skip colour draws at zero and resume live
  when the flag clears. Opaque/direct lists ignore it, preserving no-colour/ESM
  casters. Transmission and renderOnTop checks preserve texture-update ordering.
  Raw bindings remain unwrapped. Twin-bundle tests replay the same opaque bundle
  and preserve the visibility epoch while only the faded transparent draw changes.
- Scene-owned non-override explicit entries follow opacity crossings only with
  mesh refresh. Drain the real scene rebuild queue and execute the real task:
  1 → 0.5 selects transparent, 0.5 → 0 skips live, and 0.5 → 1 restores opaque
  and retires auxiliary lifetimes. Plain explicit tasks retain their renderables.
- Flag tests cover zero-to-fraction/one, invalid-call atomicity, clone isolation,
  unchanged public visibility/pickability and accessibility projection.
- Preserve Standard's live material-alpha classification, A2C plus blending,
  mirroring/VAT bindings, PBR generation identity and composed-key consistency.
- Repeated rect-area emission installer calls register one context augmenter;
  that augmenter forwards each bind's current identity and lifetime array.
- Compat raw/clamped values, independent isVisible, atomic glTF fan-out,
  no inheritance, clone/_fromLite and unsupported material assignments.
- Build probes: unused entries contain no feature body markers; opted-in entries
  retain them. Root declaration trimming and root-only exports hold;
  MeshLoDInstance is a compile-time-invalid opacity target.
- Scene354 supplies static Standard/PBR 1/0.5/0 and opaque twins, normalized
  OPAQUE/MASK cases, full-strength shadows and A2C. Its BJS golden is immutable;
  pixel/MAD coverage belongs to the existing scene parity infrastructure.

## File manifest

`src/` below means `packages/babylon-lite/src/`. Listed generic files contain
transport or integration, not feature-owned opacity comparisons.

| Area                        | Files                                                                                                                                                                 |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Native API/feature          | `src/mesh/opacity/mesh-opacity.ts`, `mesh-opacity-standard.ts`, `mesh-opacity-pbr.ts`; root exports in `src/index.ts`                                                 |
| Mesh transport              | `src/material/mesh-features.ts`                                                                                                                                       |
| Standard integration        | `src/material/standard/standard-flags.ts`, `standard-renderable.ts`, `standard-geometry-renderable.ts`                                                                |
| PBR integration             | `src/material/pbr/pbr-flags.ts`, `pbr-pipeline.ts`, `pbr-renderable.ts`, `pbr-geometry-renderable.ts`, `pbr-built-variant.ts`                                         |
| Shadow-only predicate       | `src/material/pbr/fragments/shadow-only-fragment.ts`                                                                                                                  |
| Draw filtering / validation | `src/mesh/mesh.ts`, `src/frame-graph/render-task-base.ts`, `transmission.ts`, `geometry-renderer-task.ts`, `src/scene/mesh-scene-registry.ts`                         |
| Rect-area installer guard   | `src/material/pbr/fragments/rect-area-light-emission-fragment.ts`                                                                                                     |
| Compat                      | `packages/babylon-lite-compat/src/meshes/meshes.ts`, `README.md`, `COMPAT-STATUS.md`                                                                                  |
| Architecture index          | `docs/lite/architecture/00-overview.md`                                                                                                                               |
| Native unit tests           | `tests/lite/unit/mesh-opacity.test.ts`, `mesh-opacity-shader.test.ts`, `render-pass-task.test.ts`, `geometry-renderer-task.test.ts`, `pbr-forward-generation.test.ts` |
| Rect-area regression        | `tests/lite/unit/area-light-emission-opt-in.test.ts`                                                                                                                  |
| Build tests                 | `tests/lite/build/mesh-opacity-treeshake.test.ts`, `public-api-types.test.ts`                                                                                         |
| Compat tests                | `packages/babylon-lite-compat/tests/mesh-visibility.test.ts`, `mesh-clone.test.ts`                                                                                    |
| Fixture                     | `lab/lite/scene354.html`, `bundle-scene354.html`, `babylon-ref-scene354.html`, `src/lite/scene354.ts`, `src/bjs/scene354.ts`, `src/shared/scene354-opacity.ts`        |
| Registration                | `scene-config.json`; HTML inputs, gallery and bundle tests auto-discover entries                                                                                      |
| Parity/assets               | `tests/lite/parity/scenes/scene354-mesh-opacity.spec.ts`, `reference/lite/scene354-mesh-opacity/babylon-ref-golden.png`, `lab/public/thumbnails/scene354.jpg`         |
