diff --git a/demo/index.html b/demo/index.html
index 981bafe..c89da03 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -242,6 +242,58 @@
OpenHuman Engine
+
+ Advanced Shading
+
+
+
+
+
+
+
+
+ 0.45
+
+
+
+
+ 2.0
+
+
+
+
+ 1.00
+
+
+
+
+ 0.45
+
+
+
+
+ 0.18
+
+
+
+
+
+
+
+
+
+ 0.30
+
+
+
+
+
+
+
+
+
+ 80
+
@@ -277,6 +329,7 @@ OpenHuman Engine
import {
GLContext, StateCache,
ForwardRenderer, DeferredRenderer, ShadowMap,
+ EyeRenderer, HairRenderer, buildHairCards,
Camera, Light, Node,
VertexBuffer, IndexBuffer,
Skeleton, Joint,
@@ -477,6 +530,8 @@ OpenHuman Engine
const deferredRenderer = new DeferredRenderer(glCtx, cache);
const forwardRenderer = new ForwardRenderer(glCtx, cache);
const shadowMap = new ShadowMap(glCtx, cache);
+ const eyeRenderer = new EyeRenderer(glCtx, cache);
+ const hairRenderer = new HairRenderer(glCtx, cache);
// Active renderer pointer — starts on deferred path
let renderer = deferredRenderer;
@@ -486,6 +541,9 @@ OpenHuman Engine
let shadowEnabled = true;
let iblEnabled = false;
let acesEnabled = true;
+ let sssEnabled = false;
+ let eyeEnabled = true;
+ let hairEnabled = true;
const camera = new Camera({ fov: 50, aspect: canvas.clientWidth / (canvas.clientHeight || 1), near: 0.1, far: 100 });
camera.setOrbit(0.2, Math.PI / 3.5, 3.5);
@@ -507,6 +565,28 @@ OpenHuman Engine
charNode.addChild(meshNode);
charNode.position.set(0, -1, 0);
+ // Additional demo meshes for PR#4
+ const skinNode = new Node('skinSphere');
+ skinNode.mesh = { ...meshData };
+ skinNode.position.set(-0.75, 0.05, -0.15);
+ skinNode.scale.set(0.62, 0.62, 0.62);
+ skinNode.mesh.material = {
+ baseColorFactor: [0.92, 0.62, 0.50, 1.0],
+ roughnessFactor: 0.58,
+ metallicFactor: 0.02,
+ };
+
+ const eyeNode = new Node('eye');
+ eyeNode.mesh = { vao: true }; // marker mesh for eye renderer pass
+ eyeNode.position.set(0.22, 0.15, 0.16);
+ eyeNode.scale.set(0.24, 0.24, 0.24);
+
+ const hairNode = new Node('hairCards');
+ hairNode.mesh = buildHairCards(gl, { cardCount: 48, cardLength: 0.95, cardWidth: 0.06 });
+ hairNode.position.set(0.0, 0.48, 0.0);
+
+ const sceneNodes = [meshNode, skinNode];
+
// ── Animation graph
const graph = new AnimationGraph(skeleton);
graph.addState('idle', buildIdleClip());
@@ -521,6 +601,9 @@ OpenHuman Engine
const parts = ['Deferred PBR · G-buffer'];
if (shadowEnabled) parts.push('PCF Shadows');
if (iblEnabled) parts.push('IBL');
+ if (sssEnabled) parts.push('SSS');
+ if (eyeEnabled) parts.push('Eye');
+ if (hairEnabled) parts.push('Hair');
if (acesEnabled) parts.push('ACES');
pathBadge.textContent = parts.join(' · ');
pathBadge.style.background = '#7a3c1c';
@@ -588,6 +671,81 @@ OpenHuman Engine
updatePathUI();
});
+ // SSS controls
+ const btnSSS = document.getElementById('btn-sss');
+ btnSSS.addEventListener('click', () => {
+ sssEnabled = !sssEnabled;
+ deferredRenderer.setSSSEnabled(sssEnabled);
+ btnSSS.textContent = sssEnabled ? 'ON' : 'OFF';
+ btnSSS.classList.toggle('off', !sssEnabled);
+ updatePathUI();
+ });
+ const sssStrength = document.getElementById('sss-strength');
+ const sssStrengthVal = document.getElementById('sss-strength-val');
+ sssStrength.addEventListener('input', () => {
+ const v = parseFloat(sssStrength.value);
+ sssStrengthVal.textContent = v.toFixed(2);
+ deferredRenderer.setSSSStrength(v);
+ });
+ const sssWidth = document.getElementById('sss-width');
+ const sssWidthVal = document.getElementById('sss-width-val');
+ sssWidth.addEventListener('input', () => {
+ const v = parseFloat(sssWidth.value);
+ sssWidthVal.textContent = v.toFixed(1);
+ deferredRenderer.setSSSWidth(v);
+ });
+ const sssR = document.getElementById('sss-r');
+ const sssG = document.getElementById('sss-g');
+ const sssB = document.getElementById('sss-b');
+ const sssRVal = document.getElementById('sss-r-val');
+ const sssGVal = document.getElementById('sss-g-val');
+ const sssBVal = document.getElementById('sss-b-val');
+ function applySSSColorFromUI() {
+ const r = parseFloat(sssR.value), g = parseFloat(sssG.value), b = parseFloat(sssB.value);
+ sssRVal.textContent = r.toFixed(2);
+ sssGVal.textContent = g.toFixed(2);
+ sssBVal.textContent = b.toFixed(2);
+ deferredRenderer.setSSSColor(r, g, b);
+ }
+ sssR.addEventListener('input', applySSSColorFromUI);
+ sssG.addEventListener('input', applySSSColorFromUI);
+ sssB.addEventListener('input', applySSSColorFromUI);
+ applySSSColorFromUI();
+
+ // Eye controls
+ const btnEye = document.getElementById('btn-eye');
+ btnEye.addEventListener('click', () => {
+ eyeEnabled = !eyeEnabled;
+ btnEye.textContent = eyeEnabled ? 'ON' : 'OFF';
+ btnEye.classList.toggle('off', !eyeEnabled);
+ updatePathUI();
+ });
+ const eyeIris = document.getElementById('eye-iris');
+ const eyeIrisVal = document.getElementById('eye-iris-val');
+ eyeIris.addEventListener('input', () => {
+ const v = parseFloat(eyeIris.value);
+ eyeIrisVal.textContent = v.toFixed(2);
+ eyeRenderer.setIrisDilate(v);
+ });
+
+ // Hair controls
+ const btnHair = document.getElementById('btn-hair');
+ btnHair.addEventListener('click', () => {
+ hairEnabled = !hairEnabled;
+ btnHair.textContent = hairEnabled ? 'ON' : 'OFF';
+ btnHair.classList.toggle('off', !hairEnabled);
+ updatePathUI();
+ });
+ const hairSpec = document.getElementById('hair-spec');
+ const hairSpecVal = document.getElementById('hair-spec-val');
+ hairSpec.addEventListener('input', () => {
+ const v = parseFloat(hairSpec.value);
+ hairSpecVal.textContent = v.toFixed(0);
+ hairRenderer.setSpecPower1(v);
+ // Secondary lobe is wider/softer than primary; clamp to keep it visible at low values.
+ hairRenderer.setSpecPower2(Math.max(8, v * 0.4));
+ });
+
// ── Animation controls
document.getElementById('btn-idle').addEventListener('click', () => {
graph.setBool('isTalking', false);
@@ -649,14 +807,28 @@ OpenHuman Engine
// ── Shadow pass (deferred path only, when shadows enabled)
const activeShadow = (useDeferred && shadowEnabled) ? shadowMap : null;
if (activeShadow) {
- shadowMap.render(charNode.children, light, gpuSkin);
+ shadowMap.render(sceneNodes, light, gpuSkin);
}
// ── Main render
if (useDeferred) {
- deferredRenderer.render(charNode.children, camera, [light], gpuSkin, activeShadow);
+ deferredRenderer.render(sceneNodes, camera, [light], gpuSkin, activeShadow);
} else {
- forwardRenderer.render(charNode.children, camera, [light], gpuSkin);
+ forwardRenderer.render(sceneNodes, camera, [light], gpuSkin);
+ }
+
+ // Forward overlay passes for specialised eye/hair shaders
+ if (eyeEnabled || hairEnabled) {
+ // Clear depth so eye/hair overlay passes can render on top of deferred scene color.
+ gl.clear(gl.DEPTH_BUFFER_BIT);
+ }
+ if (eyeEnabled) {
+ eyeNode.updateWorldMatrix(null);
+ eyeRenderer.render([eyeNode], camera, [light]);
+ }
+ if (hairEnabled) {
+ hairNode.updateWorldMatrix(null);
+ hairRenderer.render([hairNode], camera, [light]);
}
// ── FPS counter
diff --git a/src/renderer/DeferredRenderer.js b/src/renderer/DeferredRenderer.js
index 4f9fdb7..4318546 100644
--- a/src/renderer/DeferredRenderer.js
+++ b/src/renderer/DeferredRenderer.js
@@ -28,6 +28,7 @@
import { Shader } from '../core/Shader.js';
import { RenderTarget } from '../core/RenderTarget.js';
import { PostProcessStack } from './PostProcessStack.js';
+import { SSSPass } from './SSSPass.js';
import { Mat4 } from '../math/Mat4.js';
import { Mat3 } from '../math/Mat3.js';
@@ -463,6 +464,16 @@ export class DeferredRenderer {
depthAttachment: false,
});
+ // ── SSS post-process (skin subsurface scattering)
+ // Rendered between lighting pass and tonemap.
+ this._sssPass = new SSSPass(glContext, stateCache, { width: w, height: h });
+ // Secondary HDR target written by SSS pass; tonemap reads from it when SSS is on.
+ this._sssTarget = new RenderTarget(gl, {
+ width: w, height: h,
+ colorAttachments: ['RGBA16F'],
+ depthAttachment: false,
+ });
+
// ── Post-process stack (ACES tone-map)
this._postStack = new PostProcessStack(glContext, stateCache, { width: w, height: h });
@@ -515,10 +526,22 @@ export class DeferredRenderer {
// 3 ── Lighting pass → HDR target
this._lightingPass(camera, dirLight, shadowMap);
- // 4 ── Tone-map pass → canvas
- const gl = this.gl;
+ // 4 ── Optional SSS pass (skin) → sssTarget
+ if (this._sssPass.enabled) {
+ this._sssTarget.bind();
+ this._sssPass.render(
+ this._hdrTarget.getColorTexture(0),
+ this._gBuffer.getColorTexture(1),
+ this._gBuffer.getColorTexture(0),
+ this._gBuffer.width,
+ this._gBuffer.height
+ );
+ this._sssTarget.unbind();
+ }
+
+ // 5 ── Tone-map pass → canvas
this._postStack.render(
- this._hdrTarget.getColorTexture(0),
+ this._sssPass.enabled ? this._sssTarget.getColorTexture(0) : this._hdrTarget.getColorTexture(0),
this._gBuffer.width,
this._gBuffer.height
);
@@ -569,11 +592,33 @@ export class DeferredRenderer {
/** @param {number} v */
setIBLIntensity(v) { this._iblIntensity = Math.max(0, v); }
+ /** @param {boolean} enabled */
+ setSSSEnabled(enabled) {
+ this._sssPass.setEnabled(!!enabled);
+ }
+
+ /** @param {number} value */
+ setSSSStrength(value) {
+ this._sssPass.setStrength(value);
+ }
+
+ /** @param {number} value */
+ setSSSWidth(value) {
+ this._sssPass.setWidth(value);
+ }
+
+ /** @param {number} r @param {number} g @param {number} b */
+ setSSSColor(r, g, b) {
+ this._sssPass.setColor(r, g, b);
+ }
+
/** Handle canvas resize. */
resize(w, h) {
const cache = this.cache;
this._gBuffer.resize(w, h);
this._hdrTarget.resize(w, h);
+ this._sssTarget.resize(w, h);
+ this._sssPass.resize(w, h);
this._postStack.resize(w, h);
cache.setViewport(0, 0, w, h);
}
@@ -581,6 +626,8 @@ export class DeferredRenderer {
destroy() {
this._gBuffer?.destroy();
this._hdrTarget?.destroy();
+ this._sssTarget?.destroy();
+ this._sssPass?.destroy();
this._postStack?.destroy();
this._geoShader?.destroy();
this._geoSkinnedShader?.destroy();
@@ -589,7 +636,8 @@ export class DeferredRenderer {
if (this._brdfLUT) gl.deleteTexture(this._brdfLUT);
if (this._irradianceTex) gl.deleteTexture(this._irradianceTex);
if (this._envTex) gl.deleteTexture(this._envTex);
- this._gBuffer = this._hdrTarget = this._postStack = null;
+ this._gBuffer = this._hdrTarget = this._sssTarget = this._postStack = null;
+ this._sssPass = null;
this._geoShader = this._geoSkinnedShader = this._lightShader = null;
this._brdfLUT = this._irradianceTex = this._envTex = null;
}
diff --git a/src/renderer/EyeRenderer.js b/src/renderer/EyeRenderer.js
new file mode 100644
index 0000000..8210c6a
--- /dev/null
+++ b/src/renderer/EyeRenderer.js
@@ -0,0 +1,341 @@
+/**
+ * EyeRenderer — physically-based forward eye shader.
+ *
+ * Models a stylised but physically-motivated eye using a single sphere mesh.
+ * The fragment shader partitions the sphere surface into three zones based on
+ * the angle from the "gaze axis":
+ *
+ * Cornea (front cap) — Fresnel specular + iris parallax/depth effect
+ * Limbus (iris ring) — coloured iris with parallax offset
+ * Sclera (white shell) — warm white + subsurface scatter contribution
+ *
+ * Integration
+ * ───────────
+ * EyeRenderer is a forward pass rendered to the canvas AFTER the deferred
+ * pipeline has already tone-mapped to the default framebuffer. A fresh depth
+ * test is performed against a per-frame cleared depth buffer so eye geometry
+ * correctly occludes itself and hair but not the deferred scene.
+ *
+ * Controls
+ * ────────
+ * irisColor — base iris pigment (default: [0.18, 0.38, 0.75] blue)
+ * irisDilate — iris radius fraction (0.15 → narrow pupil, 0.32 → dilated)
+ * corneaIOR — index of refraction for parallax offset (default: 1.376)
+ * specPower — specular shininess of cornea (default: 256)
+ */
+import { Shader } from '../core/Shader.js';
+import { Mat4 } from '../math/Mat4.js';
+import { Mat3 } from '../math/Mat3.js';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Eye vertex shader
+// ─────────────────────────────────────────────────────────────────────────────
+const EYE_VERT = /* glsl */`#version 300 es
+precision highp float;
+
+in vec3 a_Position;
+in vec3 a_Normal;
+
+uniform mat4 u_ModelMatrix;
+uniform mat4 u_ViewMatrix;
+uniform mat4 u_ProjectionMatrix;
+uniform mat3 u_NormalMatrix;
+
+out vec3 v_WorldPos;
+out vec3 v_Normal;
+
+void main() {
+ vec4 wPos = u_ModelMatrix * vec4(a_Position, 1.0);
+ v_WorldPos = wPos.xyz;
+ v_Normal = normalize(u_NormalMatrix * a_Normal);
+ gl_Position = u_ProjectionMatrix * u_ViewMatrix * wPos;
+}
+`;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Eye fragment shader
+// ─────────────────────────────────────────────────────────────────────────────
+const EYE_FRAG = /* glsl */`#version 300 es
+precision highp float;
+
+in vec3 v_WorldPos;
+in vec3 v_Normal;
+
+// ── Eye parameters
+uniform vec3 u_EyeCenter; // world-space centre of the eye sphere
+uniform vec3 u_GazeAxis; // normalised gaze direction (forward = +Z)
+uniform vec3 u_IrisColor;
+uniform float u_IrisRadius; // angular radius of iris (radians), ~0.30
+uniform float u_CorneaIOR; // index of refraction ~1.376
+uniform float u_SpecPower; // cornea specular shininess
+
+// ── Lighting
+uniform vec3 u_LightDir; // normalised world-space toward-light direction
+uniform vec3 u_LightColor;
+uniform vec3 u_CameraPos;
+
+out vec4 fragColor;
+
+const float PI = 3.14159265358979;
+
+// Simple Schlick Fresnel
+vec3 schlick(vec3 F0, float cosTheta) {
+ return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
+}
+
+void main() {
+ vec3 N = normalize(v_Normal);
+ vec3 V = normalize(u_CameraPos - v_WorldPos);
+ vec3 L = normalize(u_LightDir);
+ vec3 H = normalize(V + L);
+
+ float NdotL = max(dot(N, L), 0.0);
+ float NdotV = max(dot(N, V), 0.0001);
+ float NdotH = max(dot(N, H), 0.0);
+
+ // ── Determine which zone we are in based on angle from gaze axis
+ // cosine of the angle between the outward sphere normal and the gaze axis
+ float cosGaze = dot(N, u_GazeAxis); // 1 at front, -1 at back
+
+ // Zone thresholds (cosine space)
+ float corneaThresh = cos(u_IrisRadius * 1.2); // slightly wider than iris
+ float irisThresh = cos(u_IrisRadius);
+
+ // 0 = sclera, 1 = iris/limbus, 2 = cornea
+ float inIris = smoothstep(corneaThresh - 0.03, corneaThresh + 0.03, cosGaze);
+ float inCornea = smoothstep(irisThresh - 0.03, irisThresh + 0.03, cosGaze);
+
+ // ── Sclera shading (white + warm SSS tint from light wrap-around)
+ vec3 scleraAlbedo = vec3(0.93, 0.91, 0.88);
+ // Light wrap-around for sub-surface scatter illusion
+ float wrapLight = (dot(N, L) + 0.4) / 1.4; // wrapped Lambert
+ vec3 scleraDiff = scleraAlbedo * max(0.0, wrapLight) * u_LightColor;
+ // Ambient
+ vec3 scleraAmb = scleraAlbedo * 0.12;
+ vec3 scleraCol = scleraDiff + scleraAmb;
+
+ // ── Iris shading
+ // Parallax / depth offset: iris appears recessed behind cornea
+ // Compute refracted view direction at the cornea surface using Snell's law approx.
+ float eta = 1.0 / u_CorneaIOR;
+ vec3 refDir = refract(-V, N, eta); // refracted into eye medium
+ // Virtual iris position: project along refDir by iris recess depth
+ float irisDepth = 0.12; // fraction of sphere radius
+ vec3 irisPoint = v_WorldPos + refDir * irisDepth;
+ // Use the projected point as iris UV for slight parallax variation
+ vec2 irisUV = irisPoint.xy - u_EyeCenter.xy;
+ // Simple radial pattern for iris detail (ring bands)
+ float r = length(irisUV) * 6.0;
+ float pattern = 0.85 + 0.15 * sin(r * PI);
+ vec3 irisCol = u_IrisColor * pattern * max(0.4, NdotL);
+
+ // Pupil: dark centre of iris
+ float pupilR = u_IrisRadius * 0.35; // pupil as fraction of iris
+ float pupilCos = cos(pupilR);
+ float inPupil = smoothstep(pupilCos - 0.02, pupilCos + 0.02, cosGaze);
+ irisCol = mix(vec3(0.01), irisCol, inPupil);
+
+ // ── Cornea specular (Phong + Fresnel)
+ vec3 F0cornea = vec3(0.04); // IOR ~1.376 → R0 ≈ 0.023; use 0.04 for visibility
+ vec3 fresnelC = schlick(F0cornea, NdotV);
+ float spec = pow(NdotH, u_SpecPower) * NdotL;
+ vec3 corneaSpec = fresnelC * spec * u_LightColor * 2.5;
+
+ // ── Blend zones
+ vec3 color = scleraCol;
+ color = mix(color, irisCol, inIris);
+ // Add cornea specular across the whole front hemisphere (peaks at inCornea)
+ color = color + corneaSpec * inCornea;
+
+ fragColor = vec4(color, 1.0);
+}
+`;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Procedural eye sphere
+// ─────────────────────────────────────────────────────────────────────────────
+function generateSphere(radius, stacks, slices) {
+ const pos = [], nrm = [], idx = [];
+ for (let i = 0; i <= stacks; i++) {
+ const phi = (i / stacks) * Math.PI;
+ for (let j = 0; j <= slices; j++) {
+ const theta = (j / slices) * 2 * Math.PI;
+ const x = Math.sin(phi) * Math.sin(theta);
+ const y = Math.cos(phi);
+ const z = Math.sin(phi) * Math.cos(theta);
+ pos.push(x * radius, y * radius, z * radius);
+ nrm.push(x, y, z);
+ }
+ }
+ for (let i = 0; i < stacks; i++) {
+ for (let j = 0; j < slices; j++) {
+ const a = i * (slices + 1) + j;
+ const b = a + slices + 1;
+ idx.push(a, b, a + 1, b, b + 1, a + 1);
+ }
+ }
+ return {
+ positions: new Float32Array(pos),
+ normals: new Float32Array(nrm),
+ indices: new Uint16Array(idx),
+ };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+export class EyeRenderer {
+ /**
+ * @param {import('../core/GLContext.js').GLContext} glContext
+ * @param {import('../core/StateCache.js').StateCache} stateCache
+ */
+ constructor(glContext, stateCache) {
+ this.ctx = glContext;
+ this.gl = glContext.gl;
+ this.cache = stateCache;
+
+ this._shader = new Shader(this.gl, EYE_VERT, EYE_FRAG);
+
+ // Build procedural eye sphere VAO
+ this._buildMesh();
+
+ // Pre-allocated matrices
+ this._modelMat = new Mat4();
+ this._normalMat = new Mat3();
+
+ // Eye shader parameters
+ this.irisColor = [0.18, 0.38, 0.75];
+ this.irisRadius = 0.30; // radians
+ this.corneaIOR = 1.376;
+ this.specPower = 256.0;
+ }
+
+ // ─────────────────────────────────────────── public API
+
+ /** @param {number[]} rgb */
+ setIrisColor(r, g, b) { this.irisColor = [r, g, b]; }
+ /** @param {number} v 0.15 = narrow, 0.32 = dilated */
+ setIrisDilate(v) { this.irisRadius = Math.max(0.1, Math.min(0.5, v)); }
+ /** @param {number} v index of refraction, ~1.376 */
+ setCorneaIOR(v) { this.corneaIOR = v; }
+ /** @param {number} v specular shininess */
+ setSpecPower(v) { this.specPower = v; }
+
+ /**
+ * Render all eye nodes to the current framebuffer.
+ *
+ * @param {Array<{worldMatrix: Float32Array|import('../math/Mat4.js').Mat4, mesh: object}>} nodes
+ * @param {import('../scene/Camera.js').Camera} camera
+ * @param {import('../scene/Light.js').Light[]} lights
+ */
+ render(nodes, camera, lights) {
+ if (!nodes || nodes.length === 0) return;
+
+ const gl = this.gl;
+ const cache = this.cache;
+
+ camera.updateProjection();
+ camera.updateView();
+
+ cache.setDepthTest(true);
+ cache.setDepthWrite(true);
+ cache.setBlend(false);
+ cache.setCullFace(true, gl.BACK);
+
+ cache.useProgram(this._shader.program);
+
+ const dirLight = lights?.find(l => l.type === 'directional') ?? null;
+ const lightDir = dirLight
+ ? [dirLight.direction.x, dirLight.direction.y, dirLight.direction.z]
+ : [0.5, -1.0, 0.5];
+ const lightCol = dirLight
+ ? [dirLight.color.x * dirLight.intensity, dirLight.color.y * dirLight.intensity, dirLight.color.z * dirLight.intensity]
+ : [1.0, 1.0, 1.0];
+
+ this._shader.setVec3('u_LightDir', -lightDir[0], -lightDir[1], -lightDir[2]);
+ this._shader.setVec3('u_LightColor', lightCol[0], lightCol[1], lightCol[2]);
+ const cp = camera.position.e;
+ this._shader.setVec3('u_CameraPos', cp[0], cp[1], cp[2]);
+
+ // Set view and projection once
+ this._shader.setMat4('u_ViewMatrix', camera.viewMatrix.e);
+ this._shader.setMat4('u_ProjectionMatrix', camera.projectionMatrix.e);
+
+ this._shader.setVec3('u_IrisColor', this.irisColor[0], this.irisColor[1], this.irisColor[2]);
+ this._shader.setFloat('u_IrisRadius', this.irisRadius);
+ this._shader.setFloat('u_CorneaIOR', this.corneaIOR);
+ this._shader.setFloat('u_SpecPower', this.specPower);
+
+ for (const node of nodes) {
+ if (!node.mesh) continue;
+ this._renderEyeNode(node, camera);
+ }
+ }
+
+ resize(_w, _h) { /* no internal render targets */ }
+
+ destroy() {
+ const gl = this.gl;
+ this._shader?.destroy();
+ if (this._vao) gl.deleteVertexArray(this._vao);
+ if (this._posBuf) gl.deleteBuffer(this._posBuf);
+ if (this._nrmBuf) gl.deleteBuffer(this._nrmBuf);
+ if (this._idxBuf) gl.deleteBuffer(this._idxBuf);
+ this._shader = null;
+ this._vao = null;
+ }
+
+ // ─────────────────────────────────────────── private
+
+ _buildMesh() {
+ const gl = this.gl;
+ const mesh = generateSphere(0.5, 24, 24);
+
+ this._vao = gl.createVertexArray();
+ gl.bindVertexArray(this._vao);
+
+ this._posBuf = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._posBuf);
+ gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW);
+ gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
+ gl.enableVertexAttribArray(0);
+
+ this._nrmBuf = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._nrmBuf);
+ gl.bufferData(gl.ARRAY_BUFFER, mesh.normals, gl.STATIC_DRAW);
+ gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);
+ gl.enableVertexAttribArray(1);
+
+ this._idxBuf = gl.createBuffer();
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this._idxBuf);
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices, gl.STATIC_DRAW);
+
+ this._indexCount = mesh.indices.length;
+
+ gl.bindVertexArray(null);
+ }
+
+ _renderEyeNode(node, camera) {
+ const gl = this.gl;
+ const sh = this._shader;
+
+ // Model matrix from node
+ const mxArr = node.worldMatrix?.e ?? node.worldMatrix;
+ sh.setMat4('u_ModelMatrix', mxArr);
+
+ // Normal matrix = transpose(inverse(mat3(model)))
+ const m = mxArr;
+ const nm = this._normalMat.e;
+ nm[0] = m[0]; nm[1] = m[1]; nm[2] = m[2];
+ nm[3] = m[4]; nm[4] = m[5]; nm[5] = m[6];
+ nm[6] = m[8]; nm[7] = m[9]; nm[8] = m[10];
+ sh.setMat3('u_NormalMatrix', nm);
+
+ // Eye centre = node translation (from world matrix column 3)
+ sh.setVec3('u_EyeCenter', m[12], m[13], m[14]);
+ // Gaze axis = model +Z transformed to world space (3rd column of model rotation)
+ sh.setVec3('u_GazeAxis', m[8], m[9], m[10]);
+
+ gl.bindVertexArray(this._vao);
+ gl.drawElements(gl.TRIANGLES, this._indexCount, gl.UNSIGNED_SHORT, 0);
+ gl.bindVertexArray(null);
+ }
+}
diff --git a/src/renderer/HairRenderer.js b/src/renderer/HairRenderer.js
new file mode 100644
index 0000000..4accf01
--- /dev/null
+++ b/src/renderer/HairRenderer.js
@@ -0,0 +1,437 @@
+/**
+ * HairRenderer — Kajiya-Kay anisotropic hair shader.
+ *
+ * Implements a card-based hair renderer where each hair lock is represented
+ * as a pair of triangles (a quad) with the hair flowing direction encoded as
+ * the vertex tangent. The fragment shader uses the Kajiya-Kay reflectance
+ * model with two anisotropic lobes:
+ *
+ * Primary lobe — specular highlight at the expected hair angle
+ * Secondary lobe — shifted specular highlight (simulates cuticle layer)
+ *
+ * The card edges are faded with an alpha mask derived from the V texture
+ * coordinate, so cards appear as loose strands rather than flat ribbons.
+ * Fragments below the alpha threshold are discarded.
+ *
+ * Controls
+ * ────────
+ * hairColor — base hair pigment [R, G, B]
+ * specColor1 — primary specular tint (usually light/pale)
+ * specColor2 — secondary specular tint (warm highlight)
+ * specPower1 — primary lobe exponent (default: 80)
+ * specPower2 — secondary lobe exponent (default: 32)
+ * specShift — secondary lobe tangent shift (default: 0.1)
+ * alphaThreshold — card edge discard threshold (default: 0.1)
+ *
+ * Geometry helper
+ * ───────────────
+ * HairRenderer.buildHairCards(gl, opts) returns a ready-to-render mesh object
+ * compatible with the Node.mesh convention used elsewhere in the engine.
+ */
+import { Shader } from '../core/Shader.js';
+import { Mat4 } from '../math/Mat4.js';
+import { Mat3 } from '../math/Mat3.js';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Hair vertex shader
+// ─────────────────────────────────────────────────────────────────────────────
+const HAIR_VERT = /* glsl */`#version 300 es
+precision highp float;
+
+in vec3 a_Position;
+in vec3 a_Normal;
+in vec3 a_Tangent; // along-hair direction (world-aligned at rest)
+in vec2 a_TexCoord; // u = along-hair (0 root → 1 tip), v = across card (0/1 edges)
+
+uniform mat4 u_ModelMatrix;
+uniform mat4 u_ViewMatrix;
+uniform mat4 u_ProjectionMatrix;
+uniform mat3 u_NormalMatrix;
+
+out vec3 v_WorldPos;
+out vec3 v_WorldTangent;
+out vec3 v_Normal;
+out vec2 v_TexCoord;
+
+void main() {
+ vec4 wPos = u_ModelMatrix * vec4(a_Position, 1.0);
+ v_WorldPos = wPos.xyz;
+ v_WorldTangent = normalize(u_NormalMatrix * a_Tangent);
+ v_Normal = normalize(u_NormalMatrix * a_Normal);
+ v_TexCoord = a_TexCoord;
+ gl_Position = u_ProjectionMatrix * u_ViewMatrix * wPos;
+}
+`;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Hair fragment shader — Kajiya-Kay + two-lobe anisotropic specular
+// ─────────────────────────────────────────────────────────────────────────────
+const HAIR_FRAG = /* glsl */`#version 300 es
+precision highp float;
+
+in vec3 v_WorldPos;
+in vec3 v_WorldTangent;
+in vec3 v_Normal;
+in vec2 v_TexCoord;
+
+// ── Hair material
+uniform vec3 u_HairColor;
+uniform vec3 u_SpecColor1; // primary specular
+uniform vec3 u_SpecColor2; // secondary specular
+uniform float u_SpecPower1;
+uniform float u_SpecPower2;
+uniform float u_SpecShift; // secondary lobe tangent shift
+uniform float u_AlphaThreshold;
+
+// ── Lighting
+uniform vec3 u_LightDir; // normalised toward-light
+uniform vec3 u_LightColor;
+uniform vec3 u_CameraPos;
+uniform vec3 u_AmbientColor;
+
+out vec4 fragColor;
+
+// Kajiya-Kay diffuse term: sqrt(1 - (T·L)²)
+float kkDiffuse(vec3 T, vec3 L) {
+ float TdotL = dot(T, L);
+ return sqrt(max(0.0, 1.0 - TdotL * TdotL));
+}
+
+// Kajiya-Kay specular term: -(T·L)*(T·V) + sqrt(...) approach
+// Using the sin-based form: spec = (T·H is "tangent space highlight")
+float kkSpecular(vec3 T, vec3 L, vec3 V, float exponent) {
+ vec3 H = normalize(L + V);
+ float TdotH = dot(T, H);
+ float sinTH = sqrt(max(0.0, 1.0 - TdotH * TdotH));
+ return pow(sinTH, exponent);
+}
+
+// Shift tangent along normal by a scalar (secondary lobe)
+vec3 shiftTangent(vec3 T, vec3 N, float shift) {
+ return normalize(T + shift * N);
+}
+
+void main() {
+ // ── Alpha mask — soft fade at card edges (v = 0 or v = 1)
+ float edgeMask = smoothstep(0.0, 0.12, v_TexCoord.y) *
+ smoothstep(0.0, 0.12, 1.0 - v_TexCoord.y);
+ if (edgeMask < u_AlphaThreshold) discard;
+
+ // Root-to-tip transparency: slightly more transparent at tip
+ float tipFade = mix(1.0, 0.6, v_TexCoord.x);
+ if (edgeMask * tipFade < u_AlphaThreshold * 0.5) discard;
+
+ vec3 T = normalize(v_WorldTangent);
+ vec3 N = normalize(v_Normal);
+ vec3 L = normalize(u_LightDir);
+ vec3 V = normalize(u_CameraPos - v_WorldPos);
+
+ // ── Kajiya-Kay diffuse
+ float diff = kkDiffuse(T, L);
+ vec3 diffuse = u_HairColor * diff * u_LightColor;
+
+ // ── Primary specular lobe
+ float spec1 = kkSpecular(T, L, V, u_SpecPower1);
+ vec3 primary = u_SpecColor1 * spec1 * u_LightColor;
+
+ // ── Secondary specular lobe (shifted tangent)
+ vec3 T2 = shiftTangent(T, N, u_SpecShift);
+ float spec2 = kkSpecular(T2, L, V, u_SpecPower2);
+ // Tint second lobe by hair color (subsurface-ish behaviour)
+ vec3 secondary = u_SpecColor2 * u_HairColor * spec2 * u_LightColor;
+
+ // ── Ambient
+ vec3 ambient = u_HairColor * u_AmbientColor;
+
+ // ── Combine
+ vec3 color = ambient + diffuse + primary + secondary;
+ // Apply edge+tip alpha for soft card appearance
+ float alpha = edgeMask * tipFade;
+
+ fragColor = vec4(color, alpha);
+}
+`;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Procedural hair card geometry builder
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Build a set of hair cards arranged in a crown/cap configuration.
+ *
+ * @param {WebGL2RenderingContext} gl
+ * @param {{
+ * cardCount?: number, // number of hair cards (default: 32)
+ * cardLength?: number, // card length (default: 0.9)
+ * cardWidth?: number, // card width (default: 0.06)
+ * radius?: number, // scalp dome radius (default: 0.52)
+ * droop?: number, // downward droop factor (default: 0.55)
+ * }} [opts]
+ * @returns {{ vao, indexBuffer, indexCount, indexType, material, skinned: false }}
+ */
+export function buildHairCards(gl, opts = {}) {
+ const COUNT = opts.cardCount ?? 32;
+ const LEN = opts.cardLength ?? 0.9;
+ const WIDTH = opts.cardWidth ?? 0.06;
+ const RADIUS = opts.radius ?? 0.52;
+ const DROOP = opts.droop ?? 0.55;
+
+ const positions = [];
+ const normals = [];
+ const tangents = [];
+ const uvs = [];
+ const indices = [];
+
+ for (let c = 0; c < COUNT; c++) {
+ const angle = (c / COUNT) * 2 * Math.PI;
+ const cosA = Math.cos(angle);
+ const sinA = Math.sin(angle);
+
+ // Root position on the scalp dome (slightly above a sphere at y=0 centre)
+ const rx = cosA * RADIUS * 0.92;
+ const ry = RADIUS * 0.6;
+ const rz = sinA * RADIUS * 0.92;
+
+ // Hair growth direction: outward + downward (gravity droop)
+ const gx = cosA * Math.cos(Math.PI * 0.22);
+ const gy = -DROOP;
+ const gz = sinA * Math.cos(Math.PI * 0.22);
+ const glen = Math.sqrt(gx * gx + gy * gy + gz * gz);
+ const tx = gx / glen, ty = gy / glen, tz = gz / glen;
+
+ // Perpendicular to tangent for card width (cross(tangent, up))
+ const wx = ty * 0 - tz * 1; // cross(T, Y) ≈ width direction
+ const wy = tz * 0 - tx * 0;
+ const wz = tx * 1 - ty * 0;
+ const wlen = Math.sqrt(wx * wx + wy * wy + wz * wz) || 1;
+ const bx = wx / wlen * WIDTH * 0.5;
+ const by = wy / wlen * WIDTH * 0.5;
+ const bz = wz / wlen * WIDTH * 0.5;
+
+ // Normal: cross(width, tangent) → outward from scalp
+ const nx = (by * tz - bz * ty);
+ const ny = (bz * tx - bx * tz);
+ const nz = (bx * ty - by * tx);
+ const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
+ const nnx = nx / nlen, nny = ny / nlen, nnz = nz / nlen;
+
+ // Slight random variation per card
+ const wobble = (Math.sin(c * 7.3) * 0.04);
+
+ // 4 verts per card: root-left, root-right, tip-left, tip-right
+ const vBase = positions.length / 3;
+
+ // Root left
+ positions.push(rx - bx + nnx * wobble, ry - by, rz - bz + nnz * wobble);
+ normals.push(nnx, nny, nnz);
+ tangents.push(tx, ty, tz);
+ uvs.push(0, 0);
+
+ // Root right
+ positions.push(rx + bx + nnx * wobble, ry + by, rz + bz + nnz * wobble);
+ normals.push(nnx, nny, nnz);
+ tangents.push(tx, ty, tz);
+ uvs.push(0, 1);
+
+ // Tip left
+ const tipWobble = Math.sin(c * 3.7) * 0.06;
+ positions.push(
+ rx - bx * 0.6 + tx * LEN + tipWobble,
+ ry - by * 0.6 + ty * LEN,
+ rz - bz * 0.6 + tz * LEN + tipWobble
+ );
+ normals.push(nnx, nny, nnz);
+ tangents.push(tx, ty, tz);
+ uvs.push(1, 0);
+
+ // Tip right
+ positions.push(
+ rx + bx * 0.6 + tx * LEN + tipWobble,
+ ry + by * 0.6 + ty * LEN,
+ rz + bz * 0.6 + tz * LEN + tipWobble
+ );
+ normals.push(nnx, nny, nnz);
+ tangents.push(tx, ty, tz);
+ uvs.push(1, 1);
+
+ // Two triangles: 0,1,2 and 1,3,2
+ indices.push(vBase, vBase + 1, vBase + 2);
+ indices.push(vBase + 1, vBase + 3, vBase + 2);
+ }
+
+ const posArr = new Float32Array(positions);
+ const nrmArr = new Float32Array(normals);
+ const tanArr = new Float32Array(tangents);
+ const uvArr = new Float32Array(uvs);
+ const idxArr = new Uint16Array(indices);
+
+ const vao = gl.createVertexArray();
+ gl.bindVertexArray(vao);
+
+ const upload = (data, loc, size) => {
+ const buf = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
+ gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
+ gl.enableVertexAttribArray(loc);
+ return buf;
+ };
+
+ const posBuf = upload(posArr, 0, 3);
+ const nrmBuf = upload(nrmArr, 1, 3);
+ const tanBuf = upload(tanArr, 2, 3);
+ const uvBuf = upload(uvArr, 3, 2);
+
+ const idxBuf = gl.createBuffer();
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idxArr, gl.STATIC_DRAW);
+
+ gl.bindVertexArray(null);
+
+ return {
+ vao,
+ _bufs: [posBuf, nrmBuf, tanBuf, uvBuf, idxBuf],
+ indexBuffer: { _buf: idxBuf },
+ indexCount: idxArr.length,
+ indexType: gl.UNSIGNED_SHORT,
+ skinned: false,
+ isHair: true,
+ material: {
+ baseColorFactor: [0.08, 0.05, 0.02, 1.0], // dark brown
+ roughnessFactor: 0.6,
+ metallicFactor: 0.0,
+ },
+ };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+export class HairRenderer {
+ /**
+ * @param {import('../core/GLContext.js').GLContext} glContext
+ * @param {import('../core/StateCache.js').StateCache} stateCache
+ */
+ constructor(glContext, stateCache) {
+ this.ctx = glContext;
+ this.gl = glContext.gl;
+ this.cache = stateCache;
+
+ this._shader = new Shader(this.gl, HAIR_VERT, HAIR_FRAG);
+ this._normalMat = new Mat3();
+
+ // Hair material defaults
+ this.hairColor = [0.08, 0.05, 0.02];
+ this.specColor1 = [0.9, 0.85, 0.75];
+ this.specColor2 = [0.6, 0.45, 0.25];
+ this.specPower1 = 80.0;
+ this.specPower2 = 32.0;
+ this.specShift = 0.1;
+ this.alphaThreshold = 0.05;
+ }
+
+ // ─────────────────────────────────────────── public API
+
+ /** @param {number[]} rgb base hair pigment */
+ setHairColor(r, g, b) { this.hairColor = [r, g, b]; }
+ /** @param {number[]} rgb primary specular tint */
+ setSpecColor1(r, g, b) { this.specColor1 = [r, g, b]; }
+ /** @param {number[]} rgb secondary specular tint */
+ setSpecColor2(r, g, b) { this.specColor2 = [r, g, b]; }
+ /** @param {number} v primary lobe exponent */
+ setSpecPower1(v) { this.specPower1 = v; }
+ /** @param {number} v secondary lobe exponent */
+ setSpecPower2(v) { this.specPower2 = v; }
+ /** @param {number} v secondary tangent shift */
+ setSpecShift(v) { this.specShift = v; }
+
+ /**
+ * Render all hair nodes to the current framebuffer.
+ *
+ * @param {Array} nodes
+ * @param {import('../scene/Camera.js').Camera} camera
+ * @param {import('../scene/Light.js').Light[]} lights
+ */
+ render(nodes, camera, lights) {
+ if (!nodes || nodes.length === 0) return;
+
+ const gl = this.gl;
+ const cache = this.cache;
+
+ camera.updateProjection();
+ camera.updateView();
+
+ // Two-sided rendering + alpha blend for hair transparency
+ cache.setDepthTest(true);
+ cache.setDepthWrite(false); // hair cards are semi-transparent
+ cache.setCullFace(false, gl.BACK); // render both sides
+ cache.setBlend(true);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+
+ cache.useProgram(this._shader.program);
+
+ const dirLight = lights?.find(l => l.type === 'directional') ?? null;
+ const lx = dirLight ? -dirLight.direction.x : -0.5;
+ const ly = dirLight ? -dirLight.direction.y : 1.0;
+ const lz = dirLight ? -dirLight.direction.z : -0.3;
+ const li = dirLight ? dirLight.intensity : 1.0;
+ const lcx = dirLight ? dirLight.color.x * li : 1.0;
+ const lcy = dirLight ? dirLight.color.y * li : 1.0;
+ const lcz = dirLight ? dirLight.color.z * li : 1.0;
+
+ this._shader.setVec3('u_LightDir', lx, ly, lz);
+ this._shader.setVec3('u_LightColor', lcx, lcy, lcz);
+ this._shader.setVec3('u_AmbientColor', 0.12, 0.10, 0.09);
+ const cp = camera.position.e;
+ this._shader.setVec3('u_CameraPos', cp[0], cp[1], cp[2]);
+
+ this._shader.setMat4('u_ViewMatrix', camera.viewMatrix.e);
+ this._shader.setMat4('u_ProjectionMatrix', camera.projectionMatrix.e);
+
+ this._shader.setVec3('u_HairColor', this.hairColor[0], this.hairColor[1], this.hairColor[2]);
+ this._shader.setVec3('u_SpecColor1', this.specColor1[0], this.specColor1[1], this.specColor1[2]);
+ this._shader.setVec3('u_SpecColor2', this.specColor2[0], this.specColor2[1], this.specColor2[2]);
+ this._shader.setFloat('u_SpecPower1', this.specPower1);
+ this._shader.setFloat('u_SpecPower2', this.specPower2);
+ this._shader.setFloat('u_SpecShift', this.specShift);
+ this._shader.setFloat('u_AlphaThreshold', this.alphaThreshold);
+
+ for (const node of nodes) {
+ if (!node.mesh) continue;
+ this._renderNode(node);
+ }
+
+ // Restore depth write
+ cache.setDepthWrite(true);
+ cache.setBlend(false);
+ cache.setCullFace(true, gl.BACK);
+ }
+
+ resize(_w, _h) { /* no internal render targets */ }
+
+ destroy() {
+ this._shader?.destroy();
+ this._shader = null;
+ }
+
+ // ─────────────────────────────────────────── private
+
+ _renderNode(node) {
+ const gl = this.gl;
+ const sh = this._shader;
+
+ const mxArr = node.worldMatrix?.e ?? node.worldMatrix;
+ sh.setMat4('u_ModelMatrix', mxArr);
+
+ // Normal matrix (upper-left 3×3 of model matrix, non-uniform scale not handled for simplicity)
+ const m = mxArr;
+ const nm = this._normalMat.e;
+ nm[0] = m[0]; nm[1] = m[1]; nm[2] = m[2];
+ nm[3] = m[4]; nm[4] = m[5]; nm[5] = m[6];
+ nm[6] = m[8]; nm[7] = m[9]; nm[8] = m[10];
+ sh.setMat3('u_NormalMatrix', nm);
+
+ const mesh = node.mesh;
+ gl.bindVertexArray(mesh.vao);
+ gl.drawElements(gl.TRIANGLES, mesh.indexCount, mesh.indexType ?? gl.UNSIGNED_SHORT, 0);
+ gl.bindVertexArray(null);
+ }
+}
diff --git a/src/renderer/SSSPass.js b/src/renderer/SSSPass.js
new file mode 100644
index 0000000..f90b2c6
--- /dev/null
+++ b/src/renderer/SSSPass.js
@@ -0,0 +1,275 @@
+/**
+ * SSSPass — Jorge Jimenez-inspired separable screen-space subsurface scattering.
+ *
+ * Applies a two-pass (horizontal + vertical) edge-stopped Gaussian blur to the
+ * HDR lighting buffer. The blurred "scattered" result is additively composited
+ * back onto the original with a configurable per-channel color tint (the warm
+ * red-dominant skin scatter profile) and a global strength scalar.
+ *
+ * Pass order
+ * ──────────
+ * 1. Horizontal Gaussian blur (HDR → _tmpRT)
+ * 2. Vertical Gaussian blur + composite (HDR + _tmpRT → output target / canvas)
+ *
+ * Both passes use the G-buffer normal texture for edge-stopping: samples whose
+ * surface normals deviate too far from the centre pixel are down-weighted,
+ * preventing colour bleeding across hard geometry boundaries.
+ *
+ * Controls
+ * ────────
+ * enabled — on/off toggle (default: false)
+ * strength — blend factor 0–2 (default: 0.5)
+ * width — kernel step size in pixels (default: 2.0)
+ * color — vec3 RGB scatter tint, e.g. [1, 0.45, 0.18] for warm skin
+ */
+import { Shader } from '../core/Shader.js';
+import { RenderTarget } from '../core/RenderTarget.js';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Shared fullscreen-triangle vertex shader
+// ─────────────────────────────────────────────────────────────────────────────
+const FULLSCREEN_VERT = /* glsl */`#version 300 es
+precision highp float;
+void main() {
+ vec2 pos;
+ pos.x = (gl_VertexID == 1) ? 3.0 : -1.0;
+ pos.y = (gl_VertexID == 2) ? 3.0 : -1.0;
+ gl_Position = vec4(pos, 0.0, 1.0);
+}
+`;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Horizontal Gaussian blur pass
+// ─────────────────────────────────────────────────────────────────────────────
+const SSS_H_FRAG = /* glsl */`#version 300 es
+precision highp float;
+
+uniform sampler2D u_HDR; // linear HDR lighting buffer
+uniform sampler2D u_GNormalRough; // G-buffer RT1: worldNormal.xyz (encoded 0..1)
+uniform float u_Width; // kernel step multiplier (pixels)
+
+out vec4 fragColor;
+
+// 7-tap symmetric Gaussian (sigma ≈ 1.9)
+const int TAPS = 7;
+const float OFF[7] = float[](-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0);
+const float WGT[7] = float[](0.03, 0.09, 0.19, 0.38, 0.19, 0.09, 0.03);
+
+void main() {
+ ivec2 fc = ivec2(gl_FragCoord.xy);
+
+ // Centre normal for edge-stopping
+ vec3 cN = texelFetch(u_GNormalRough, fc, 0).xyz * 2.0 - 1.0;
+
+ vec3 result = vec3(0.0);
+ float totalW = 0.0;
+
+ for (int i = 0; i < TAPS; i++) {
+ ivec2 sc = fc + ivec2(int(OFF[i] * u_Width), 0);
+ vec3 sN = texelFetch(u_GNormalRough, sc, 0).xyz * 2.0 - 1.0;
+ // Edge-stop: reduce weight when normals diverge
+ float edgeW = pow(max(0.0, dot(cN, sN)), 12.0);
+ float w = WGT[i] * edgeW;
+ result += texelFetch(u_HDR, sc, 0).rgb * w;
+ totalW += w;
+ }
+
+ if (totalW > 0.001) result /= totalW;
+ fragColor = vec4(result, 1.0);
+}
+`;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Vertical Gaussian blur pass + composite with original HDR
+// ─────────────────────────────────────────────────────────────────────────────
+const SSS_V_COMPOSITE_FRAG = /* glsl */`#version 300 es
+precision highp float;
+
+uniform sampler2D u_HDROriginal; // original (un-blurred) HDR
+uniform sampler2D u_HDRHBlurred; // horizontally blurred HDR
+uniform sampler2D u_GNormalRough; // G-buffer RT1
+uniform sampler2D u_AlbedoAO; // G-buffer RT0 (for skin mask)
+uniform float u_Width; // kernel step multiplier
+uniform float u_Strength; // SSS blend strength (0..1+)
+uniform vec3 u_SSSColor; // per-channel scatter tint
+
+out vec4 fragColor;
+
+const int TAPS = 7;
+const float OFF[7] = float[](-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0);
+const float WGT[7] = float[](0.03, 0.09, 0.19, 0.38, 0.19, 0.09, 0.03);
+
+void main() {
+ ivec2 fc = ivec2(gl_FragCoord.xy);
+
+ // Centre normal for edge-stopping
+ vec3 cN = texelFetch(u_GNormalRough, fc, 0).xyz * 2.0 - 1.0;
+
+ // Vertical Gaussian over the horizontal-blurred buffer
+ vec3 blurred = vec3(0.0);
+ float totalW = 0.0;
+
+ for (int i = 0; i < TAPS; i++) {
+ ivec2 sc = fc + ivec2(0, int(OFF[i] * u_Width));
+ vec3 sN = texelFetch(u_GNormalRough, sc, 0).xyz * 2.0 - 1.0;
+ float edgeW = pow(max(0.0, dot(cN, sN)), 12.0);
+ float w = WGT[i] * edgeW;
+ blurred += texelFetch(u_HDRHBlurred, sc, 0).rgb * w;
+ totalW += w;
+ }
+ if (totalW > 0.001) blurred /= totalW;
+
+ // Composite: add color-tinted scattered component onto original
+ vec3 original = texelFetch(u_HDROriginal, fc, 0).rgb;
+ vec3 albedo = texelFetch(u_AlbedoAO, fc, 0).rgb;
+
+ // Cheap skin mask from albedo hue (warm/red-biased surfaces get more SSS)
+ float redDom = albedo.r - max(albedo.g, albedo.b);
+ // 3.0 boosts weak red-dominance into a usable mask range for typical skin albedo.
+ // 0.35 is a base threshold so neutral warm tones still receive subtle SSS.
+ float skinMask = clamp(redDom * 3.0 + 0.35, 0.0, 1.0);
+
+ // Scattered = tinted blurred - original, represents extra scattering
+ vec3 scattered = blurred * u_SSSColor;
+ // Additive blend: original + scatter contribution
+ vec3 result = original + (scattered - original) * clamp(u_Strength, 0.0, 1.0) * skinMask;
+
+ fragColor = vec4(result, 1.0);
+}
+`;
+
+// ─────────────────────────────────────────────────────────────────────────────
+export class SSSPass {
+ /**
+ * @param {import('../core/GLContext.js').GLContext} glContext
+ * @param {import('../core/StateCache.js').StateCache} stateCache
+ * @param {{ width?: number, height?: number }} [opts]
+ */
+ constructor(glContext, stateCache, opts = {}) {
+ this.ctx = glContext;
+ this.gl = glContext.gl;
+ this.cache = stateCache;
+
+ this._width = opts.width ?? glContext.canvas.width ?? 1;
+ this._height = opts.height ?? glContext.canvas.height ?? 1;
+
+ // Shader for horizontal pass
+ this._hShader = new Shader(this.gl, FULLSCREEN_VERT, SSS_H_FRAG);
+ // Shader for vertical pass + composite
+ this._vShader = new Shader(this.gl, FULLSCREEN_VERT, SSS_V_COMPOSITE_FRAG);
+
+ // Temporary render target for horizontal blur output
+ this._tmpRT = new RenderTarget(this.gl, {
+ width: this._width, height: this._height,
+ colorAttachments: ['RGBA16F'],
+ depthAttachment: false,
+ });
+
+ // SSS parameters
+ this.enabled = false;
+ this.strength = 0.5;
+ this.width = 2.0;
+ this.color = [1.0, 0.45, 0.18]; // warm skin scatter profile
+ }
+
+ // ─────────────────────────────────────────── public API
+
+ /** @param {boolean} v */
+ setEnabled(v) { this.enabled = !!v; }
+ /** @param {number} v 0 → no SSS, 1 → full SSS */
+ setStrength(v) { this.strength = Math.max(0, v); }
+ /** @param {number} v blur kernel step size in pixels */
+ setWidth(v) { this.width = Math.max(0.5, v); }
+ /** @param {number} r @param {number} g @param {number} b */
+ setColor(r, g, b) { this.color = [r, g, b]; }
+
+ /**
+ * Execute the SSS pass.
+ *
+ * Reads from hdrTex + gNormalTex, writes result to the currently-bound
+ * framebuffer (or canvas if null).
+ *
+ * @param {WebGLTexture} hdrTex — HDR lighting result
+ * @param {WebGLTexture} gNormalTex — G-buffer RT1 (worldNormal * 0.5 + 0.5)
+ * @param {WebGLTexture} gAlbedoTex — G-buffer RT0 (albedo + ao)
+ * @param {number} w
+ * @param {number} h
+ */
+ render(hdrTex, gNormalTex, gAlbedoTex, w, h) {
+ if (!this.enabled) return;
+
+ const gl = this.gl;
+ const cache = this.cache;
+
+ this._ensureTmp(w, h);
+
+ cache.setDepthTest(false);
+ cache.setDepthWrite(false);
+ cache.setBlend(false);
+ cache.setCullFace(false, gl.BACK);
+
+ // ── Pass 1: horizontal blur → _tmpRT
+ this._tmpRT.bind();
+ cache.setViewport(0, 0, w, h);
+ cache.useProgram(this._hShader.program);
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, hdrTex);
+ this._hShader.setInt('u_HDR', 0);
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, gNormalTex);
+ this._hShader.setInt('u_GNormalRough', 1);
+ this._hShader.setFloat('u_Width', this.width);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+ this._tmpRT.unbind();
+
+ // ── Pass 2: vertical blur + composite → caller's bound framebuffer
+ cache.setViewport(0, 0, w, h);
+ cache.useProgram(this._vShader.program);
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, hdrTex);
+ this._vShader.setInt('u_HDROriginal', 0);
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, this._tmpRT.getColorTexture(0));
+ this._vShader.setInt('u_HDRHBlurred', 1);
+ gl.activeTexture(gl.TEXTURE2);
+ gl.bindTexture(gl.TEXTURE_2D, gNormalTex);
+ this._vShader.setInt('u_GNormalRough', 2);
+ gl.activeTexture(gl.TEXTURE3);
+ gl.bindTexture(gl.TEXTURE_2D, gAlbedoTex);
+ this._vShader.setInt('u_AlbedoAO', 3);
+ this._vShader.setFloat('u_Width', this.width);
+ this._vShader.setFloat('u_Strength', this.strength);
+ this._vShader.setVec3('u_SSSColor', this.color[0], this.color[1], this.color[2]);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+ }
+
+ /** Handle canvas resize. */
+ resize(w, h) {
+ if (this._width === w && this._height === h) return;
+ this._width = w;
+ this._height = h;
+ this._tmpRT?.resize(w, h);
+ }
+
+ destroy() {
+ this._hShader?.destroy();
+ this._vShader?.destroy();
+ this._tmpRT?.destroy();
+ this._hShader = this._vShader = this._tmpRT = null;
+ }
+
+ // ─────────────────────────────────────────── private
+
+ /** Lazily resize or create the temporary horizontal-blur render target. */
+ _ensureTmp(w, h) {
+ if (!this._tmpRT) {
+ this._tmpRT = new RenderTarget(this.gl, {
+ width: w, height: h,
+ colorAttachments: ['RGBA16F'],
+ depthAttachment: false,
+ });
+ } else {
+ this._tmpRT.resize(w, h);
+ }
+ }
+}
diff --git a/src/sdk/OpenHuman.js b/src/sdk/OpenHuman.js
index ae3f04e..d444c34 100644
--- a/src/sdk/OpenHuman.js
+++ b/src/sdk/OpenHuman.js
@@ -187,7 +187,32 @@ class OpenHumanInstance {
self._renderer.setIBLEnabled(enabled);
}
},
- setSSSStrength(v) { /* TODO: subsurface scattering strength uniform */ },
+ setSSSEnabled(enabled) {
+ if (self._renderer instanceof DeferredRenderer) {
+ self._renderer.setSSSEnabled(enabled);
+ }
+ },
+ setSSSStrength(v) {
+ if (self._renderer instanceof DeferredRenderer) {
+ self._renderer.setSSSStrength(v);
+ }
+ },
+ setSSSWidth(v) {
+ if (self._renderer instanceof DeferredRenderer) {
+ self._renderer.setSSSWidth(v);
+ }
+ },
+ setSSSColor(r, g, b) {
+ if (self._renderer instanceof DeferredRenderer) {
+ self._renderer.setSSSColor(r, g, b);
+ }
+ },
+ setEyeParams(params = {}) {
+ self._eyeParams = { ...(self._eyeParams ?? {}), ...params };
+ },
+ setHairParams(params = {}) {
+ self._hairParams = { ...(self._hairParams ?? {}), ...params };
+ },
};
}
@@ -332,6 +357,9 @@ export class OpenHuman {
export { GLContext, StateCache, ForwardRenderer, DeferredRenderer, ShadowMap, GLTFLoader, Camera, Light, Character, Node };
export { VertexBuffer, IndexBuffer };
export { PostProcessStack } from '../renderer/PostProcessStack.js';
+export { SSSPass } from '../renderer/SSSPass.js';
+export { EyeRenderer } from '../renderer/EyeRenderer.js';
+export { HairRenderer, buildHairCards } from '../renderer/HairRenderer.js';
export { Skeleton, Joint } from '../animation/Skeleton.js';
export { AnimationClip, Pose } from '../animation/AnimationClip.js';
export { AnimationGraph } from '../animation/AnimationGraph.js';