407 lines
17 KiB
TypeScript
407 lines
17 KiB
TypeScript
import { Color, DoubleSide, MeshBasicMaterial, MeshStandardMaterial } from 'three'
|
|
|
|
import type { ResourceScope } from '@vibe3d/ownership.ts'
|
|
|
|
/**
|
|
* The kit's shared material set.
|
|
*
|
|
* The critical choice: every material is `vertexColors: true`. Colour
|
|
* variation is carried in the GEOMETRY, not in the material. That way 13
|
|
* boards can have 13 separate tones while all of them share a single
|
|
* material, and therefore a single draw call.
|
|
*
|
|
* This is the small version of the idea behind scifi-kit's wear pipeline:
|
|
* write the surface identity into a vertex attribute, then merge.
|
|
*/
|
|
export type MedievalSlot =
|
|
| 'oak' // timber
|
|
| 'iron' // wrought iron
|
|
| 'steel' // steel burnished by use
|
|
| 'brass' // bronze and copper: bells, coins
|
|
| 'straw' // straw, wicker, besom bristles
|
|
| 'cloth' // linen, sackcloth, parchment
|
|
| 'leather' // leather: book covers, pouches
|
|
| 'glass' // blown glass
|
|
| 'produce' // fruit and vegetable skin
|
|
| 'ember' // flame — does not take light, it emits it
|
|
| 'char' // charcoal, pitch
|
|
| 'stone' // dressed and rubble masonry
|
|
| 'water' // standing water
|
|
|
|
/**
|
|
* The material TYPE can differ per slot. `ember` is an unlit MeshBasicMaterial;
|
|
* the rest are PBR. Thanks to this mapping the model code knows at compile
|
|
* time which type it is getting.
|
|
*/
|
|
export interface SlotMaterial {
|
|
readonly oak: MeshStandardMaterial
|
|
readonly iron: MeshStandardMaterial
|
|
readonly steel: MeshStandardMaterial
|
|
readonly brass: MeshStandardMaterial
|
|
readonly straw: MeshStandardMaterial
|
|
readonly cloth: MeshStandardMaterial
|
|
readonly leather: MeshStandardMaterial
|
|
readonly glass: MeshStandardMaterial
|
|
readonly produce: MeshStandardMaterial
|
|
readonly ember: MeshBasicMaterial
|
|
readonly char: MeshStandardMaterial
|
|
readonly stone: MeshStandardMaterial
|
|
readonly water: MeshStandardMaterial
|
|
}
|
|
|
|
export interface MedievalPalette {
|
|
/** Oak body tone. */
|
|
readonly oak: Color
|
|
/** Lid board — a little cooler and lighter than the body (end grain). */
|
|
readonly oakEnd: Color
|
|
/** Wrought iron: fresh off the anvil, the oxide layer still on it. */
|
|
readonly iron: Color
|
|
/** Steel burnished by use: the anvil's face, the shovel's blade, the fork's tines. */
|
|
readonly steel: Color
|
|
/** Bronze — bells, mortars, coins. */
|
|
readonly brass: Color
|
|
/**
|
|
* Bell bronze that has stood outside.
|
|
*
|
|
* Separate from `brass` because they are not the same colour and the models
|
|
* that use each are not the same models. Fresh bronze on a coin or a book's
|
|
* corner boss is the yellow `brass` is; a bell that has hung in weather for a
|
|
* century is dark and grey. Measured against a reference photograph the bell
|
|
* sits at hue 33, saturation 0.34, value 0.33, against 39 / 0.56 / 0.56 for
|
|
* the brass we were painting it with.
|
|
*
|
|
* This is a PALETTE entry, not a slot: the mesh still resolves through the
|
|
* `brass` material. The palette carries colour, the slot carries material,
|
|
* and keeping them separate is what lets one model recolour without dragging
|
|
* the coin pouch and the book's fittings along with it.
|
|
*/
|
|
readonly bronze: Color
|
|
/** Dry straw. */
|
|
readonly straw: Color
|
|
/** Straw tips bleached by the sun. */
|
|
readonly strawPale: Color
|
|
/** Raw linen / sackcloth. */
|
|
readonly cloth: Color
|
|
/** Tanned leather. */
|
|
readonly leather: Color
|
|
/** Blown glass — slightly greenish, glass of the period was not clear. */
|
|
readonly glass: Color
|
|
/** Base tone of fruit skin. The actual colour comes from the model's `hue` field. */
|
|
readonly produce: Color
|
|
/** Base of the flame — hot and bright. */
|
|
readonly ember: Color
|
|
/** Tip of the flame — more saturated, more red. */
|
|
readonly emberTip: Color
|
|
/** Burnt-out charcoal. */
|
|
readonly char: Color
|
|
/** Charcoal that is still glowing. */
|
|
readonly charHot: Color
|
|
/**
|
|
* Masonry.
|
|
*
|
|
* The kit had no stone at all, which for a medieval catalogue is a hole
|
|
* rather than an omission: a well, a trough, a millstone, a boundary wall
|
|
* and a hearth are all stone before they are anything else. Its colour is
|
|
* close to weathered oak on purpose -- what separates the two at a glance is
|
|
* not hue but that stone scatters light completely flat, which is carried by
|
|
* the material's roughness rather than by the palette.
|
|
*/
|
|
readonly stone: Color
|
|
|
|
/**
|
|
* Living bark, which is NOT the timber colour and is not close to it.
|
|
*
|
|
* Measured off a photograph of a standing oak: hue 70 degrees, saturation
|
|
* 0.17. Sawn oak in this palette sits at hue 26 and saturation 0.36 -- the
|
|
* bark is 44 degrees cooler and half as saturated, because it is grey-green
|
|
* with lichen and algae on it rather than brown. Reaching for the `oak` key
|
|
* on a trunk gives a plank standing on end.
|
|
*
|
|
* Measured off the WINTER photograph, after the first attempt took it off
|
|
* the summer one and came out 28 degrees too green. A trunk under a crown in
|
|
* full leaf is lit through the leaves, and the green it bounces down is in
|
|
* every pixel of it; the same tree bare reads hue 42 and saturation 0.27
|
|
* against 70 and 0.17. Two photographs of the same species disagreeing by
|
|
* that much is not noise, it is one of them measuring the light instead of
|
|
* the bark.
|
|
*
|
|
* The low saturation of the first attempt made it worse than the hue alone:
|
|
* a nearly neutral colour takes its hue from whatever is lighting it, and in
|
|
* this kit's renderer that is a blue-grey sky, which dragged the rendered
|
|
* bole all the way to hue 119 -- properly green, on a tree whose bark is
|
|
* brown. Saturation here is what holds the hue still.
|
|
*
|
|
* Lightness is the one number not taken straight: the photograph averages
|
|
* 0.21, but a good part of that is shadow inside bark fissures millimetres
|
|
* deep which nine flat facets cannot reproduce. The straw note below is the
|
|
* same trap.
|
|
*/
|
|
readonly bark: Color
|
|
|
|
|
|
/**
|
|
* Weathered limestone, which is NOT the same rock as `stone`.
|
|
*
|
|
* `stone` was measured off dressed and rubble masonry: hue 44, saturation
|
|
* 0.05 — a wall stone, grey, and grey because a wall is quarried and laid
|
|
* face out. A trough is one block left in the open for a century, and the
|
|
* same measurement on one reads hue 36, saturation 0.16, lightness 0.48:
|
|
* warmer, THREE TIMES more saturated, and lighter. Reaching for `stone` on
|
|
* it gives a concrete planter.
|
|
*
|
|
* Both are correct and neither replaces the other, which is why this is a
|
|
* palette key rather than an edit to `stone` — the well and the forge were
|
|
* each measured against their own references and are not this rock.
|
|
*/
|
|
readonly limestone: Color
|
|
|
|
/**
|
|
* Standing water, and it is far less green than it feels.
|
|
*
|
|
* Sampled inside the reference trough's basin the film reads hue 39.5 —
|
|
* which is the hue of the stone underneath it, not a colour of its own. It
|
|
* is shallow and it is clear; what it does is DARKEN and slightly saturate
|
|
* whatever it lies on (lightness 0.48 down to 0.36, saturation 0.16 up to
|
|
* 0.23). So this is nearly neutral, barely cool, and it does its work
|
|
* through the material's transparency rather than through its hue. Painted
|
|
* the pond green a first-instinct would have reached for, the composite
|
|
* would have missed the reference by 100 degrees of hue.
|
|
*/
|
|
readonly water: Color
|
|
}
|
|
|
|
/**
|
|
* The colours are written as sRGB; with ColorManagement enabled three stores
|
|
* them internally in linear space. Since the vertex colour attribute expects
|
|
* linear values, `color.r/g/b` can be written straight through — no extra
|
|
* conversion needed.
|
|
*/
|
|
/**
|
|
* Base colours, measured against reference photographs rather than chosen.
|
|
*
|
|
* The hue was already right -- our oak sits at 26 degrees and real oak in a
|
|
* photograph sits at 27 -- but the saturation was consistently too high, by a
|
|
* median of 0.08 across ten wooden models, and worst on the ones made almost
|
|
* entirely of bare timber: the ladder and the fence were each 0.16 over. That
|
|
* is the difference between weathered oak and new pine, and it was making the
|
|
* whole kit read as plastic.
|
|
*
|
|
* Straw was left alone for a long time on an argument that was half right, and
|
|
* the half that was wrong cost four models. The original measurement said straw
|
|
* was both too yellow and too washed out. The saturation half was rejected for a
|
|
* good reason, still true: a photograph of a bale reads as desaturated because
|
|
* it is thousands of individual straws each shading its neighbour, and pushing a
|
|
* flat lowpoly surface to that number does not reproduce the texture, it only
|
|
* makes the surface garish. Saturation now measures within 0.03 of the
|
|
* references, so that call was correct.
|
|
*
|
|
* The hue half was thrown out with it, and nothing checked lightness at all.
|
|
* Measured against the references, straw sat 11 degrees too yellow and a median
|
|
* 0.18 too light, worst on the broom and the stall awning at 0.30 and 0.29. A
|
|
* bale came out the colour of butter. Rejecting the inference from one axis is
|
|
* not a reason to discard the others that were measured beside it.
|
|
*
|
|
* That saturation pass was right and it was not the whole story. It closed the
|
|
* gap it measured: the render now sits within 0.03 of the references on
|
|
* saturation. Then it concluded from two axes that colour was finished. The
|
|
* axis it never measured was lightness, and across nine timber models the
|
|
* render is a median 0.13 lighter than its reference, worst on the chest at
|
|
* 0.21 and the trestle table at 0.17. Hue is four degrees short as well,
|
|
* sitting at 25 where the references sit at 29.
|
|
*
|
|
* A light, red-shifted brown is pink, which is the word eleven separate critics
|
|
* reached for. The earlier note that what remained was geometry rather than
|
|
* colour came from a measurement that could not have seen the problem. The
|
|
* lesson is not that the numbers lied. It is that a clean result on the axes
|
|
* you chose says nothing about the axis you did not.
|
|
*/
|
|
export const MEDIEVAL_PALETTE: MedievalPalette = {
|
|
oak: new Color(0x5c442e),
|
|
oakEnd: new Color(0x705942),
|
|
iron: new Color(0x40464d),
|
|
steel: new Color(0x8d979f),
|
|
brass: new Color(0xa9843f),
|
|
bronze: new Color(0x63523e),
|
|
straw: new Color(0x7f592e),
|
|
strawPale: new Color(0xb5834a),
|
|
cloth: new Color(0xa48965),
|
|
leather: new Color(0x6b452c),
|
|
glass: new Color(0xbcd4cb),
|
|
produce: new Color(0xa8452f),
|
|
ember: new Color(0xffd27a),
|
|
emberTip: new Color(0xd8571b),
|
|
char: new Color(0x241f1c),
|
|
stone: new Color(0x6e6b63),
|
|
charHot: new Color(0xc4441a),
|
|
bark: new Color(0x585032),
|
|
limestone: new Color(0x94846b),
|
|
water: new Color(0x3b4e45),
|
|
}
|
|
|
|
/**
|
|
* Creates materials for the requested slots.
|
|
*
|
|
* The slot list is required on purpose: the brazier does not need oak, the
|
|
* barrel does not need an emissive material. Creating unused materials is both
|
|
* a wasted GPU resource and a lie that contradicts the model's `materialSlots`
|
|
* declaration.
|
|
*
|
|
* The return type is narrowed to the requested slots, so `materials.ember`
|
|
* only compiles in a model that asked for it.
|
|
*/
|
|
export function createMedievalMaterials<S extends MedievalSlot>(
|
|
scope: ResourceScope,
|
|
slots: readonly S[],
|
|
): Pick<SlotMaterial, S> {
|
|
const build: { [K in MedievalSlot]: () => SlotMaterial[K] } = {
|
|
oak: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / oak',
|
|
// White base: all colour information comes from the vertex colour, so
|
|
// the material must not multiply it away.
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
roughness: 0.82,
|
|
metalness: 0,
|
|
}),
|
|
// Iron occurs in two states and they cannot be told with ONE material.
|
|
// The anvil's body is forged, oxidised, matte; its face is like a mirror
|
|
// because it has been worked on for years. The difference between them is
|
|
// not one of colour but of ROUGHNESS — vertex colour cannot carry that,
|
|
// because roughness is not an attribute.
|
|
//
|
|
// Hence two slots: `iron` is the forged surface, `steel` is the surface
|
|
// the work has touched. It is normal for a model to want both; it is worth
|
|
// a separate draw call, because a bright cutting edge sells the model on
|
|
// its own.
|
|
iron: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / wrought iron',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
roughness: 0.62,
|
|
metalness: 0.78,
|
|
}),
|
|
steel: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / burnished steel',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
// Low roughness + high metalness, i.e. almost fully reflective. This
|
|
// only works if there is an environment map; in a scene without an
|
|
// environment it looks pitch black. The viewer supplies a PMREM sky.
|
|
roughness: 0.19,
|
|
metalness: 0.95,
|
|
}),
|
|
brass: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / bronze',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
roughness: 0.38,
|
|
metalness: 0.85,
|
|
}),
|
|
// Straw and cloth are fully matte: metalness 0, roughness almost 1. The
|
|
// difference between them is not in the numbers but in the vertex colours
|
|
// — both follow the same lighting model, but keeping them in separate
|
|
// slots matters, because a model's `materialSlots` declaration is a
|
|
// CONTRACT: a straw bale declaring an "oak" slot would be a lie told to
|
|
// the consumer.
|
|
straw: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / straw',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
roughness: 0.94,
|
|
metalness: 0,
|
|
}),
|
|
cloth: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / linen',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
roughness: 0.97,
|
|
metalness: 0,
|
|
}),
|
|
leather: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / leather',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
// Leather is not fully matte; being oiled, it has a slight sheen.
|
|
roughness: 0.66,
|
|
metalness: 0,
|
|
}),
|
|
// Glass is a thin SHELL, not a solid block. That has two consequences:
|
|
// `side` has to be DoubleSide (otherwise it disappears when seen from
|
|
// inside) and `depthWrite` has to be off (otherwise it hides the wick
|
|
// behind it).
|
|
glass: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / blown glass',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
roughness: 0.08,
|
|
metalness: 0,
|
|
transparent: true,
|
|
opacity: 0.34,
|
|
depthWrite: false,
|
|
side: DoubleSide,
|
|
}),
|
|
// Fruit skin is waxy: not fully matte, but not metallic either. The reason
|
|
// it is a separate slot is not the name but the BEHAVIOUR — had I given it
|
|
// the same roughness as straw, an apple would look like dry hay.
|
|
produce: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / produce',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
roughness: 0.52,
|
|
metalness: 0,
|
|
}),
|
|
// A flame does not TAKE light, it emits it. MeshStandardMaterial would be
|
|
// the wrong tool here: `emissive` is a single Color, it is not fed from
|
|
// vertex colours — so no colour gradient from the flame's base to its tip
|
|
// is possible. MeshBasicMaterial skips lighting entirely and shows the
|
|
// vertex colour as it is; toneMapped is off so the scene exposure cannot
|
|
// put the flame out.
|
|
ember: () => new MeshBasicMaterial({
|
|
name: 'medieval-kit / ember',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
toneMapped: false,
|
|
}),
|
|
stone: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / stone',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
// Rougher than anything else here. Dressed stone is not polished and
|
|
// rubble certainly is not; any sheen at all reads as ceramic.
|
|
roughness: 0.97,
|
|
metalness: 0,
|
|
}),
|
|
// Water is a THIN TRANSPARENT FILM, not a body of liquid. Everything in
|
|
// this kit that holds water holds a few centimetres of it over a floor you
|
|
// can see, so the colour that reaches the eye is mostly what is underneath
|
|
// — which is why the opacity is the important number here and the hue is
|
|
// nearly not a number at all. Smooth, because a trough out of the wind is
|
|
// the one surface in this kit that reflects.
|
|
water: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / standing water',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
roughness: 0.11,
|
|
metalness: 0,
|
|
transparent: true,
|
|
opacity: 0.72,
|
|
}),
|
|
char: () => new MeshStandardMaterial({
|
|
name: 'medieval-kit / charcoal',
|
|
color: 0xffffff,
|
|
vertexColors: true,
|
|
roughness: 0.95,
|
|
metalness: 0,
|
|
}),
|
|
}
|
|
|
|
const materials = {} as { [K in S]: SlotMaterial[K] }
|
|
for (const slot of slots) {
|
|
// TypeScript cannot follow correlated unions: `build[slot]` widens to a
|
|
// union of functions, so it cannot know that the return is exactly
|
|
// SlotMaterial[slot]. The cast is safe because the mapping is built by
|
|
// hand above.
|
|
materials[slot] = scope.ownMaterial(build[slot]()) as SlotMaterial[S]
|
|
}
|
|
return materials
|
|
}
|