// flight/emissive.js (Lane B) — the one material behind every glowing thing B draws. // // ART_BIBLE: gameplay-relevant things glow, and the scene has NO lights (walls fake theirs // with fresnel). So our primitives are unlit: a core colour with a rim that hots up at // grazing angles, which reads as a volume against the dark tint instead of a flat decal. // One material per (colour, rim) pair + InstancedMesh = 1 draw call per enemy/projectile // type, which is how the whole combat layer fits in its 80-draw budget. // // Lives in flight/ rather than combat/ to keep the dependency arrow one-way: combat imports // flight (it needs the player), flight never imports combat. import * as THREE from 'three'; // ART_BIBLE §Emissive code — the readability law. Never hand-pick a colour outside this. export const EMISSIVE = { hostile: { core: 0xff5a2a, rim: 0xffb488 }, // hot amber -> red core hostileShot: { core: 0xffe9d0, rim: 0xffffff }, // white-hot neutral: { core: 0x39e6ff, rim: 0xdff2ff }, // cyan (player + flora + interactive) ally: { core: 0x33ffbe, rim: 0xd6fff0 }, // teal — an active friendly unit (phage). // distinct from cyan "don't shoot" flora: // this one HELPS, and reads as its own thing. pickup: { core: 0x7dffb0, rim: 0xdaffe9 }, gate: { core: 0xb06aff, rim: 0xe6d0ff }, acid: { core: 0xc8ff3a, rim: 0xeaffb0 }, }; /** * `matcap` (optional) — the wet-tissue sheen, and the reason it is safe to put on a system whose * whole job is colour-coding: it modulates BRIGHTNESS ONLY. The sampled matcap is reduced to a * luminance and multiplied into the core, so an amber foe stays exactly amber and a cyan * commensal stays exactly cyan — the ART_BIBLE readability law is untouched by construction, * while the silhouette stops being a flat glow and starts reading as a lit, wet, three- * dimensional object. That is the whole ask from playtest: detail, without changing what the * colours mean. */ export function emissiveMat({ core, rim }, { additive = false, opacity = 1, matcap = null } = {}) { return new THREE.ShaderMaterial({ defines: matcap ? { USE_ENT_MATCAP: '' } : {}, transparent: opacity < 1 || additive, depthWrite: !additive, blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending, uniforms: { uCore: { value: new THREE.Color(core) }, uRim: { value: new THREE.Color(rim) }, uOpacity: { value: opacity }, uFlash: { value: 0 }, // driven on hit: 0..1 blows the whole body to rim colour uMatcap: { value: matcap }, uMatcapGain: { value: 0.85 }, // how much of the form the matcap is allowed to carve }, vertexShader: /* glsl */` varying vec3 vN; varying vec3 vV; // PER-INSTANCE HIT FLASH. Declared unconditionally on purpose: a geometry that carries no // aFlash simply leaves the attribute unbound, and WebGL feeds the default 0 — so a plain // Mesh and an instanced pool that has not opted in both behave exactly as before. attribute float aFlash; varying float vFlash; void main() { vFlash = aFlash; // three declares the instanceMatrix attribute for us whenever the drawn object is // an InstancedMesh (USE_INSTANCING); this same material still works on a plain Mesh. // (Careful: no backticks in here — this is a JS template literal.) #ifdef USE_INSTANCING mat4 m = modelViewMatrix * instanceMatrix; #else mat4 m = modelViewMatrix; #endif vec4 mv = m * vec4(position, 1.0); // mat3(m) is only a true normal matrix under uniform scale — every instance we // emit is uniformly scaled, and normalize() eats the leftover constant. vN = normalize(mat3(m) * normal); vV = -mv.xyz; gl_Position = projectionMatrix * mv; }`, fragmentShader: /* glsl */` varying vec3 vN; varying vec3 vV; varying float vFlash; uniform vec3 uCore; uniform vec3 uRim; uniform float uOpacity; uniform float uFlash; #ifdef USE_ENT_MATCAP uniform sampler2D uMatcap; uniform float uMatcapGain; #endif void main() { vec3 N = normalize(vN); float f = pow(1.0 - abs(dot(N, normalize(vV))), 2.0); vec3 core = uCore; #ifdef USE_ENT_MATCAP // View-space normal -> matcap uv, the same lookup the wall uses. Reduced to LUMINANCE // and applied multiplicatively: form, never hue. The pedestal keeps unlit faces // readable instead of letting them go black, because a dark half on a 1.4-unit enemy // in a fogged tube is a lost enemy. float m = dot(texture2D(uMatcap, N.xy * 0.5 + 0.5).rgb, vec3(0.299, 0.587, 0.114)); core *= (1.0 - uMatcapGain) + uMatcapGain * (0.45 + 1.25 * m); #endif vec3 col = mix(core, uRim, f) + uRim * f * 0.6; // max(), not add: uFlash is the WHOLE-COHORT tell (the boss's open-node glow, which is // per-type by nature) and vFlash is the per-instance hit. The louder one wins, so a hit // still reads on a node that is already glowing. col = mix(col, uRim * 1.6, max(uFlash, vFlash)); gl_FragColor = vec4(col, uOpacity); // MANDATORY for any custom ShaderMaterial in this project. ColorManagement is on, // so THREE.Color(0xff5a2a) is converted to LINEAR working space on the way in, but // a hand-written fragment shader gets no automatic conversion on the way out — it // would write linear straight into the sRGB target. Measured: hostile amber landed // as rgb(255,26,6) blood-red instead of rgb(255,90,42), quietly breaking the // ART_BIBLE emissive readability law. This chunk applies linear -> sRGB. #include }`, }); }