From 4d58e6e9e98139d77fc22185fda3421206b4b49f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:41:13 +0000 Subject: [PATCH 1/3] Initial plan From 99278d1721c213bf89aa1ddceeaeb83528414ce9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:50:39 +0000 Subject: [PATCH 2/3] Changes before error encountered Co-authored-by: hmthanh <8927701+hmthanh@users.noreply.github.com> Agent-Logs-Url: https://github.com/openhuman-ai/realistic-render-engine/sessions/9a4c7314-170d-4565-934d-2c1dc1e11714 --- src/renderer/DeferredRenderer.js | 11 + src/renderer/EyeRenderer.js | 341 ++++++++++++++++++++++++ src/renderer/HairRenderer.js | 437 +++++++++++++++++++++++++++++++ src/renderer/SSSPass.js | 262 ++++++++++++++++++ 4 files changed, 1051 insertions(+) create mode 100644 src/renderer/EyeRenderer.js create mode 100644 src/renderer/HairRenderer.js create mode 100644 src/renderer/SSSPass.js diff --git a/src/renderer/DeferredRenderer.js b/src/renderer/DeferredRenderer.js index 4f9fdb7..db06625 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 }); 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..039a9ef --- /dev/null +++ b/src/renderer/SSSPass.js @@ -0,0 +1,262 @@ +/** + * 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 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; + // 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); + + 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 {number} w + * @param {number} h + */ + render(hdrTex, gNormalTex, 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); + 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); + } + } +} From 27a2b3fd9bde298dda90364801c745dd9b49e365 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:10:23 +0000 Subject: [PATCH 3/3] feat: continue PR4 with deferred SSS integration, SDK controls, and demo advanced shading UI Co-authored-by: hmthanh <8927701+hmthanh@users.noreply.github.com> Agent-Logs-Url: https://github.com/openhuman-ai/realistic-render-engine/sessions/d2e3e476-fbef-443d-ae5d-0354453f9c39 --- demo/index.html | 178 ++++++++++++++++++++++++++++++- src/renderer/DeferredRenderer.js | 45 +++++++- src/renderer/SSSPass.js | 17 ++- src/sdk/OpenHuman.js | 30 +++++- 4 files changed, 260 insertions(+), 10 deletions(-) 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 db06625..4318546 100644 --- a/src/renderer/DeferredRenderer.js +++ b/src/renderer/DeferredRenderer.js @@ -526,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 ); @@ -580,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); } @@ -592,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(); @@ -600,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/SSSPass.js b/src/renderer/SSSPass.js index 039a9ef..f90b2c6 100644 --- a/src/renderer/SSSPass.js +++ b/src/renderer/SSSPass.js @@ -88,6 +88,7 @@ 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 @@ -120,10 +121,18 @@ void main() { // 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); + vec3 result = original + (scattered - original) * clamp(u_Strength, 0.0, 1.0) * skinMask; fragColor = vec4(result, 1.0); } @@ -182,10 +191,11 @@ export class SSSPass { * * @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, w, h) { + render(hdrTex, gNormalTex, gAlbedoTex, w, h) { if (!this.enabled) return; const gl = this.gl; @@ -224,6 +234,9 @@ export class SSSPass { 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]); 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';