@pieai/swimmer-render-kit
v0.5.0
Published
Shared Three.js colour pipeline contract, grade presets, and single-encode guard for PieAI Web3D products.
Maintainers
Readme
SwimmerRenderKit
Shared Three.js colour pipeline contract, grade presets, and a single-encode guard for PieAI Web3D products.
Implements SPEC-0007.
What this package is for
Six PieAI Web3D products need the same picture quality. Before this package, the same colour grade had been written three times — once tuned in YaZu, re-derived by hand in Break, and started again in weaker form in OwnMySpace.
Installing a package does not fix that: @react-three/postprocessing was already
in three of those products, and absent from the one with the best picture. What
is actually hard, and therefore worth sharing, is:
- the pipeline order — linear scene target → ACES → grade → exactly one sRGB encode — which fails silently when it is wrong;
- a set of named starting looks, so a project begins from something tuned;
- a guard against double tone-mapping and double sRGB encoding.
What it deliberately does not own
- The
EffectComposer, its passes, or their ordering. - Bloom, ambient occlusion, SMAA, or any other effect.
- Any React or R3F component.
threeis an optional peer; there is no React dependency. - Per-project constants. Presets are starting points, not finished looks.
- Lighting. A grade cannot rescue an unlit scene.
Install
pnpm add @pieai/swimmer-render-kitUse
Optional colored-clay surface (0.4)
/clay exports a structural shader adapter, not a material, renderer, texture,
lighting rig or post pass. It retains the host's Standard/Physical surface,
color maps, skinning and instancing. Object-space kneading and filtered tool
marks drive coupled pigment, roughness and shallow normals, without displacement.
import { createClaySurfaceAdapter } from '@pieai/swimmer-render-kit/clay';
const clay = createClaySurfaceAdapter({ amount: 1, scale: 1.5, relief: 0.12 });
const previous = material.onBeforeCompile;
const previousKey = material.customProgramCacheKey();
material.onBeforeCompile = (shader, renderer) => {
previous.call(material, shader, renderer);
clay.onBeforeCompile(shader);
};
material.customProgramCacheKey = () => `${previousKey}/${clay.customProgramCacheKey()}`;
clay.setAmount(0); // Uniform update: original response, no geometry rebuild.Use an owned clone when source materials are shared by a loader or avatar kit. The host owns disposal, source restoration, role-specific scales and saved user preferences. For an opaque clay variant, disable Physical transmission and anisotropy on the clone; this shader does not change transparency or render queues. Coat, sheen and iridescence are attenuated by the appearance amount. No dependency on Three.js runtime or React is added. Missing shader anchors fail before either stage is modified; existing grade/output ownership is unchanged.
Opt-in polymer finish (0.5)
For a quieter hand-formed polymer-clay surface, use the same adapter with
finish: 'polymer'. The 0.4 sculpted finish remains the default, unchanged.
const clay = createClaySurfaceAdapter({
finish: 'polymer', scale: 1.5, relief: 0.018, grain: 0.65, roughness: 0.84,
});Polymer has no directed sinusoidal furrows: scattered broad pressure and
independently filtered fine grain use C2-continuous object-space fields.
Grain slope is amplitude-normalized by frequency and adjusted for model scale;
pigment variation remains shallow, without bleaching the host's colors.
grain is in [0,1], roughness in [0.55,0.98]. Existing amount, scale,
relief and pigment retain their contracts. Selecting a finish changes the
program key; switching requires compiling from the original shader, not stacking
finishes on an already patched program. These are material starting points,
not a promise that arbitrary sharp geometry will look hand-sculpted.
Pick a preset and re-measure the pivot
import { defineGrade, srgbToDisplayLinear } from '@pieai/swimmer-render-kit';
// Measure your ungraded midtone (p50) in sRGB 8-bit from a controlled capture,
// then convert. Do NOT pass p50/255 — that is the common mistake.
export const grade = defineGrade('diorama', {
contrastPivot: srgbToDisplayLinear(72.4),
});The pivot is the one value you must set yourself. YaZu's donor used 0.5,
correct for a dungeon lit so midtones sit near half, and wrong for YaZu, whose
measured midtone is 0.066 display-linear — expanding around 0.5 pushed the
whole frame down.
Raw EffectComposer path
The final blit owns ACES and the sRGB encode, so the renderer must own neither.
import { buildGradeFragment, GRADE_VERTEX_SHADER, createGradeUniformValues }
from '@pieai/swimmer-render-kit/shader';
renderer.toneMapping = THREE.NoToneMapping;
renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
const material = new THREE.RawShaderMaterial({
name: 'PieSwimmerGradeOutput',
defines: { ACES_FILMIC_TONE_MAPPING: '', SRGB_TRANSFER: '' },
uniforms: toThreeUniforms(createGradeUniformValues(grade)),
vertexShader: GRADE_VERTEX_SHADER,
fragmentShader: buildGradeFragment(grade, { target: 'standalone' }),
});RawShaderMaterial matters: a plain ShaderMaterial lets three inject a second
tonemap and encode.
@react-three/postprocessing path
The library's composer already applies tone mapping and the encode, so the effect body must add neither.
import { buildGradeFragment } from '@pieai/swimmer-render-kit/shader';
import { Effect } from 'postprocessing';
class PieGrade extends Effect {
constructor() {
super('PieGrade', buildGradeFragment(grade, { target: 'main-image' }), {
uniforms: new Map(/* ... */),
});
}
}This target omits the tilt-shift blur, because a mainImage body has no texture
handle to sample neighbours from. Use a separate depth-of-field effect if you
need it.
Guard the pipeline
import { assertSingleColorEncode } from '@pieai/swimmer-render-kit/guard';
if (import.meta.env.DEV) {
assertSingleColorEncode(renderer, gradeFragmentSources);
}Throws when the frame tone-maps or sRGB-encodes anything other than exactly
once. This package runs in the browser and does not sniff NODE_ENV; gate the
call behind your own dev flag, or pass { throwOnFailure: false } and log the
report.
Pass sources, not passes. three's Pass exposes neither name nor
material, so composer.passes is opaque. Hand over an array of fragment
source strings — the guard accepts those directly.
Say who owns the output when it is not obvious. There are three owners and they count differently:
| outputOwner | Who writes the final frame | What is counted |
| --- | --- | --- |
| renderer | three, straight to canvas | renderer.toneMapping and outputColorSpace |
| composer | a blit you can read (standalone) | only the pass sources |
| library | @react-three/postprocessing | an implicit 1 + 1, plus anything your effect adds |
The guard infers composer when a source encodes to sRGB, and renderer
otherwise. It cannot infer library, because a correct main-image body looks
identical to an empty one — declare it on that path:
assertSingleColorEncode(renderer, [effectFragment], { outputOwner: 'library' });The distinction is not pedantry. On the composer path the renderer's settings
are inert — three skips tone mapping and the encode on the intermediate
targets a composer renders into. A product whose grade can be switched off must
leave the renderer capable of encoding for the ungraded frames, and that is
correct configuration, not a mistake to fix so an assertion goes green. YaZu
ships exactly that.
Presets
| Preset | For |
| --- | --- |
| neutral | Correct pipeline, no look. Satisfies the colour-pipeline baseline before art direction lands. |
| diorama | Miniature/tilt-shift board look. Values measured on YaZu's mission board. |
| night-street | Night exterior; cool shadows pushed, highlights held back so practicals stay hot. |
| stage | Single lit subject on a controlled background; minimal vignette. |
Verify
pnpm install
pnpm verify