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 @@