diff --git a/examples/interior-mapping/README.md b/examples/interior-mapping/README.md new file mode 100644 index 00000000..f520b068 --- /dev/null +++ b/examples/interior-mapping/README.md @@ -0,0 +1,39 @@ +# Interior Mapping(室内映射) + +实现知乎专栏 [Interior Mapping室内映射技术拆解](https://zhuanlan.zhihu.com/p/2071667769171513389) +中的预投影 2D Atlas 室内映射:在无真实室内几何的情况下,透过窗户看到有透视深度的房间。 + +## 技术要点 + +1. **切线空间射线–AABB 求交**:每个窗户 tile 视为单位房间盒,用视线与远平面交点采样。 +2. **伪透视缩放**:`scale = B / (B + depthNorm)`,对预投影贴图做相似三角形校正。 +3. **房间图集**:运行时生成 4×4 正交预投影房间 Atlas,支持顺序 / 随机选房。 +4. **前景遮挡**:文章中的深度图 Ray March 在此用**过程化家具 AABB** 代替(同算法:步进 + 二分求精),便于示例自包含、不依赖额外 set-1 深度贴图。 +5. **窗框 + 掠射菲涅尔**:浅角时偏反射,掩盖预投影极限。 + +## 运行 + +```sh +make run/linux-debug GAME=examples/interior-mapping +# 或 +cd examples/interior-mapping && ../../build/linux-debug/src/engine/eve run +``` + +## 操作 + +| 键 | 作用 | +| --- | --- | +| `O` | 开关自动环绕 | +| `A` / `D` | 偏航 | +| `W` / `S` | 俯仰 | +| `[` / `]` | 房间深度 | +| `-` / `=` | 透视强度 | +| `F` | 开关过程化家具遮挡 | +| `R` | 开关随机房间 | +| `1` / `2` / `3` | 浅 / 默认 / 深预设 | + +## 文件 + +- `shaders/interior_mapping.frag` — Mesh3D 自定义片元着色器 +- `main.nut` — 场景、Atlas 生成、参数绑定 +- `config.nut` — 窗口配置 diff --git a/examples/interior-mapping/config.nut b/examples/interior-mapping/config.nut new file mode 100644 index 00000000..b15b586b --- /dev/null +++ b/examples/interior-mapping/config.nut @@ -0,0 +1,8 @@ +config = { + width = 1280 + height = 720 + title = "EVEngine — Interior Mapping" + debug = true + hotReload = true + modules = ["gfx", "win", "fs", "image"] +}; diff --git a/examples/interior-mapping/main.nut b/examples/interior-mapping/main.nut new file mode 100644 index 00000000..2c4ab161 --- /dev/null +++ b/examples/interior-mapping/main.nut @@ -0,0 +1,317 @@ +// Interior Mapping demo — pre-projected 2D room atlas + tangent-space +// ray–AABB (Zhihu: 「Interior Mapping室内映射技术拆解」). +// +// Controls: +// O toggle auto-orbit +// A/D yaw +// W/S pitch +// [ / ] room depth +// - / = perspective strength +// F toggle procedural foreground furniture +// R toggle random room selection +// 1/2/3 preset looks (shallow / default / deep) + +persist imShader = null +persist imCamera = null +persist imFacade = null +persist imGround = null +persist imAtlas = null +persist imTime = 0.0 +persist imYaw = 0.35 +persist imPitch = 0.18 +persist imOrbit = true +persist imRoomDepth = 0.55 +persist imPerspective = 1.15 +persist imRandomRoom = 1.0 +persist imShowFg = 1.0 +persist imFgSteps = 16.0 +persist imStatus = "orbit on" + +const ATLAS_COLS = 4; +const ATLAS_ROWS = 4; +const CELL = 128; +const WIN_X = 6; +const WIN_Y = 4; + +function clampf(v, a, b) { + if (v < a) return a; + if (v > b) return b; + return v; +} + +function pushUniforms() { + imShader.sendFloat("atlasColumns", ATLAS_COLS.tofloat()); + imShader.sendFloat("atlasRows", ATLAS_ROWS.tofloat()); + imShader.sendFloat("perspective", imPerspective); + imShader.sendFloat("roomDepth", imRoomDepth); + imShader.sendFloat("randomRoom", imRandomRoom); + imShader.sendFloat("randomSeed", 3.0); + imShader.sendFloat("fgSteps", imFgSteps); + imShader.sendFloat("time", imTime); + imShader.sendFloat("showFg", imShowFg); + imShader.sendFloat("frameWidth", 0.055); +} + +function roomPalette(id) { + // Distinct wall / floor / ceiling / accent per room cell. + local walls = [ + [0.78, 0.74, 0.68], + [0.55, 0.68, 0.72], + [0.72, 0.58, 0.52], + [0.62, 0.66, 0.55], + [0.70, 0.62, 0.78], + [0.80, 0.72, 0.58], + [0.58, 0.60, 0.68], + [0.74, 0.70, 0.62], + [0.66, 0.74, 0.70], + [0.76, 0.66, 0.60], + [0.60, 0.70, 0.78], + [0.68, 0.60, 0.58], + [0.72, 0.72, 0.66], + [0.58, 0.64, 0.58], + [0.70, 0.58, 0.66], + [0.64, 0.68, 0.74] + ]; + return walls[id % walls.len()]; +} + +function paintRoom(img, ox, oy, id) { + local wall = roomPalette(id); + local floorC = [wall[0] * 0.55, wall[1] * 0.48, wall[2] * 0.42]; + local ceilC = [wall[0] * 1.08, wall[1] * 1.06, wall[2] * 1.05]; + local accent = [ + 0.35 + 0.4 * ((id * 17) % 5).tofloat() / 4.0, + 0.25 + 0.35 * ((id * 9) % 7).tofloat() / 6.0, + 0.20 + 0.45 * ((id * 13) % 4).tofloat() / 3.0 + ]; + local backDoor = (id % 3) == 0; + local windowSide = (id % 5) == 2; + local shelf = (id % 4) != 1; + + for (local y = 0; y < CELL; ++y) { + for (local x = 0; x < CELL; ++x) { + local u = (x + 0.5) / CELL.tofloat(); + local v = (y + 0.5) / CELL.tofloat(); + // Orthographic "pre-projection": floor / ceiling / side walls / back. + local col = wall; + if (v < 0.18) { + col = ceilC; + } else if (v > 0.78) { + // Floor with simple board lines. + local board = ((u * 8.0).tointeger() % 2) == 0 ? 0.92 : 1.0; + col = [floorC[0] * board, floorC[1] * board, floorC[2] * board]; + } else if (u < 0.12) { + col = [wall[0] * 0.82, wall[1] * 0.82, wall[2] * 0.85]; + } else if (u > 0.88) { + col = [wall[0] * 0.88, wall[1] * 0.86, wall[2] * 0.84]; + } else { + // Back wall with optional door / art / window. + col = wall; + if (backDoor && u > 0.38 && u < 0.62 && v > 0.28 && v < 0.78) { + col = [0.22, 0.18, 0.14]; + if (u > 0.56 && u < 0.60 && v > 0.48 && v < 0.54) { + col = [0.75, 0.65, 0.35]; // knob + } + } + if (windowSide && u > 0.70 && u < 0.86 && v > 0.32 && v < 0.62) { + col = [0.45, 0.62, 0.82]; + } + if (shelf && u > 0.16 && u < 0.34 && v > 0.40 && v < 0.70) { + local onShelf = ((v * 20.0).tointeger() % 4) == 0; + if (onShelf) col = [accent[0] * 0.5, accent[1] * 0.5, accent[2] * 0.5]; + else col = accent; + } + // Ceiling lamp hint. + if (u > 0.46 && u < 0.54 && v > 0.20 && v < 0.28) { + col = [0.95, 0.90, 0.70]; + } + } + // Soft vignette toward edges of the cell (helps perspective read). + local edge = clampf(u * 4.0, 0.0, 1.0) * clampf((1.0 - u) * 4.0, 0.0, 1.0) * + clampf(v * 3.0, 0.0, 1.0) * clampf((1.0 - v) * 3.0, 0.0, 1.0); + edge = 0.75 + 0.25 * edge; + img.setPixel(ox + x, oy + y, col[0] * edge, col[1] * edge, col[2] * edge, 1.0); + } + } +} + +function buildAtlas() { + local imageModule = eve.Image(); + local w = ATLAS_COLS * CELL; + local h = ATLAS_ROWS * CELL; + local pixels = imageModule.newEmptyImageData(w, h, "RGBA8"); + local id = 0; + for (local row = 0; row < ATLAS_ROWS; ++row) { + for (local col = 0; col < ATLAS_COLS; ++col) { + // Atlas V grows downward in ImageData; shader UV V grows up conventionally + // for Mesh3D — paint so row 0 is the top of the image (v=1 in GL). + local oy = (ATLAS_ROWS - 1 - row) * CELL; + paintRoom(pixels, col * CELL, oy, id); + id += 1; + } + } + imAtlas = gfx.newTexture(pixels, false, false); + gfx.setTextureSampler(imAtlas, "linear", "none", 1.0, 0.0); +} + +function makeFacadeMesh() { + // Single wall in XY facing +Z, UVs tiled by window grid. + local hw = WIN_X.tofloat() * 0.5; + local hh = WIN_Y.tofloat() * 0.5; + local pos = [ + -hw, -hh, 0.0, + hw, -hh, 0.0, + hw, hh, 0.0, + -hw, hh, 0.0 + ]; + local nrm = [ + 0.0, 0.0, 1.0, + 0.0, 0.0, 1.0, + 0.0, 0.0, 1.0, + 0.0, 0.0, 1.0 + ]; + local uv = [ + 0.0, 0.0, + WIN_X.tofloat(), 0.0, + WIN_X.tofloat(), WIN_Y.tofloat(), + 0.0, WIN_Y.tofloat() + ]; + local idx = [0, 1, 2, 0, 2, 3]; + return gfx.newMeshFromArrays(pos, nrm, uv, 4, idx, 6); +} + +function updateCamera() { + local dist = 7.2; + local cx = dist * cos(imPitch) * sin(imYaw); + local cy = 0.4 + dist * sin(imPitch); + local cz = dist * cos(imPitch) * cos(imYaw); + imCamera.setEye(cx, cy, cz); + imCamera.setTarget(0.0, 0.15, 0.0); +} + +function applyPreset(which) { + if (which == 1) { + imRoomDepth = 0.35; + imPerspective = 0.85; + imStatus = "preset shallow"; + } else if (which == 2) { + imRoomDepth = 0.55; + imPerspective = 1.15; + imStatus = "preset default"; + } else { + imRoomDepth = 0.78; + imPerspective = 1.6; + imStatus = "preset deep"; + } +} + +eve_init = function() { + gfx.setBackgroundColor(0.12, 0.14, 0.18, 1.0); + if (imShader == null) { + imShader = gfx.newMeshShader(fs.readText("shaders/interior_mapping.frag")); + imShader.declareFloat("atlasColumns"); + imShader.declareFloat("atlasRows"); + imShader.declareFloat("perspective"); + imShader.declareFloat("roomDepth"); + imShader.declareFloat("randomRoom"); + imShader.declareFloat("randomSeed"); + imShader.declareFloat("fgSteps"); + imShader.declareFloat("time"); + imShader.declareFloat("showFg"); + imShader.declareFloat("frameWidth"); + } + if (imAtlas == null) buildAtlas(); + if (imFacade == null) { + imFacade = eve.Renderable3D(); + imFacade.setMesh(makeFacadeMesh()); + imFacade.setShader(imShader); + imFacade.setTexture(imAtlas); + imFacade.setTint(1.0, 1.0, 1.0, 1.0); + imFacade.setCastShadow(false); + imFacade.setReceiveShadow(false); + imFacade.setPosition(0.0, 0.9, 0.0); + } + if (imGround == null) { + imGround = eve.Renderable3D(); + imGround.setMesh(gfx.newMeshCube(1.0)); + imGround.setPosition(0.0, -0.12, 0.0); + imGround.setScale(10.0, 0.2, 6.0); + imGround.setTint(0.18, 0.19, 0.22, 1.0); + imGround.setRoughness(0.95); + imGround.setCastShadow(false); + } + if (imCamera == null) { + imCamera = eve.Camera3D(); + imCamera.setFov(42.0); + imCamera.setAmbient(0.22, 0.24, 0.28); + imCamera.setActive(true); + } + gfx.setDirectionalLight(-0.35, 0.75, 0.55, 1.35, 1.25, 1.15); + pushUniforms(); + updateCamera(); + print("Interior Mapping: O orbit | A/D yaw | [/] depth | -/= perspective | F furniture | R random\n"); +}; + +eve_asset_reload <- function(path) { + if (path.find("interior_mapping.frag") != null) { + local src = fs.readText("shaders/interior_mapping.frag"); + local result = gfx.replaceShaderFromGlsl(imShader, "", src); + if (result.ok) { + imStatus = "shader reloaded"; + print("[interior-mapping] shader reloaded\n"); + } else { + imStatus = "compile failed"; + print("[interior-mapping] compile failed\n"); + } + } +}; + +eve_update = function(dt) { + imTime += dt; + if (imOrbit) imYaw += dt * 0.22; + + if (key_just_pressed("o") || key_just_pressed("O")) { + imOrbit = !imOrbit; + imStatus = imOrbit ? "orbit on" : "orbit off"; + } + if (key_just_pressed("a") || key_just_pressed("A")) imYaw -= 0.12; + if (key_just_pressed("d") || key_just_pressed("D")) imYaw += 0.12; + if (key_just_pressed("w") || key_just_pressed("W")) imPitch = clampf(imPitch + 0.06, -0.2, 0.55); + if (key_just_pressed("s") || key_just_pressed("S")) imPitch = clampf(imPitch - 0.06, -0.2, 0.55); + + if (key_just_pressed("[")) { + imRoomDepth = clampf(imRoomDepth - 0.05, 0.1, 0.9); + imStatus = "depth " + imRoomDepth; + } + if (key_just_pressed("]")) { + imRoomDepth = clampf(imRoomDepth + 0.05, 0.1, 0.9); + imStatus = "depth " + imRoomDepth; + } + if (key_just_pressed("-") || key_just_pressed("_")) { + imPerspective = clampf(imPerspective - 0.1, 0.2, 3.0); + imStatus = "perspective " + imPerspective; + } + if (key_just_pressed("=") || key_just_pressed("+")) { + imPerspective = clampf(imPerspective + 0.1, 0.2, 3.0); + imStatus = "perspective " + imPerspective; + } + if (key_just_pressed("f") || key_just_pressed("F")) { + imShowFg = imShowFg > 0.5 ? 0.0 : 1.0; + imStatus = imShowFg > 0.5 ? "furniture on" : "furniture off"; + } + if (key_just_pressed("r") || key_just_pressed("R")) { + imRandomRoom = imRandomRoom > 0.5 ? 0.0 : 1.0; + imStatus = imRandomRoom > 0.5 ? "random rooms" : "tiled rooms"; + } + if (key_just_pressed("1")) applyPreset(1); + if (key_just_pressed("2")) applyPreset(2); + if (key_just_pressed("3")) applyPreset(3); + + pushUniforms(); + updateCamera(); +}; + +eve_render = function() { + gfx.clear(); + gfx.render3D(); +}; diff --git a/examples/interior-mapping/shaders/interior_mapping.frag b/examples/interior-mapping/shaders/interior_mapping.frag new file mode 100644 index 00000000..97eb80dd --- /dev/null +++ b/examples/interior-mapping/shaders/interior_mapping.frag @@ -0,0 +1,241 @@ +#version 450 +// Interior Mapping (pre-projected 2D atlas) — based on +// 「Interior Mapping室内映射技术拆解」: tangent-space ray–AABB, +// perspective correction, atlas room selection, optional foreground +// ray-march against procedural furniture bounds. + +layout(location = 0) in vec3 vNormal; +layout(location = 1) in vec2 vUV; +layout(location = 2) in vec4 vTint; +layout(location = 3) in vec3 vWorldPos; +layout(location = 4) in vec3 vCameraPos; +layout(location = 5) in vec3 vViewPos; +layout(location = 0) out vec4 outColor; + +struct Light3D { + vec4 posRadius; + vec4 color; +}; + +layout(set = 0, binding = 0, std140) uniform Frame { + mat4 mvp; + mat4 model; + vec4 lightDirIntensity; + vec4 lightColor; + vec4 tint; + vec4 cameraPos; + vec4 ambient; + Light3D lights[8]; + vec4 texBomb; + vec4 parallax; + mat4 view; + vec4 clipInfo; + vec4 cloud; + vec4 cloudWind; +} ubo; + +layout(set = 0, binding = 1) uniform sampler2D albedoSampler; + +layout(push_constant) uniform Externals { + float data[32]; +} pc; + +// pc layout (declareFloat order): +// 0 atlasColumns 1 atlasRows 2 perspective 3 roomDepth +// 4 randomRoom 5 randomSeed 6 fgSteps 7 time +// 8 showFg 9 frameWidth + +float hash21(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} + +vec2 hash22(vec2 p) { + float n = hash21(p); + return vec2(n, hash21(p + n + 19.19)); +} + +// Cotangent frame (Mesh3D has no mesh tangents). +mat3 cotangentFrame(vec3 N, vec3 p, vec2 uv) { + vec3 dp1 = dFdx(p); + vec3 dp2 = dFdy(p); + vec2 duv1 = dFdx(uv); + vec2 duv2 = dFdy(uv); + vec3 dp2perp = cross(dp2, N); + vec3 dp1perp = cross(N, dp1); + vec3 T = dp2perp * duv1.x + dp1perp * duv2.x; + vec3 B = dp2perp * duv1.y + dp1perp * duv2.y; + float invMax = inversesqrt(max(dot(T, T), dot(B, B))); + return mat3(T * invMax, B * invMax, N); +} + +// Far-hit only ray vs unit box [-1,1]^3 (Joost / article form). +vec3 rayBoxFarHit(vec3 rayPos, vec3 rayDir) { + vec3 safeDir = vec3( + abs(rayDir.x) < 1e-5 ? (rayDir.x < 0.0 ? -1e-5 : 1e-5) : rayDir.x, + abs(rayDir.y) < 1e-5 ? (rayDir.y < 0.0 ? -1e-5 : 1e-5) : rayDir.y, + abs(rayDir.z) < 1e-5 ? (rayDir.z < 0.0 ? -1e-5 : 1e-5) : rayDir.z); + vec3 invDir = 1.0 / safeDir; + vec3 tFar = abs(invDir) - rayPos * invDir; + float t = min(min(tFar.x, tFar.y), tFar.z); + return rayPos + t * rayDir; +} + +// Per-room furniture AABB in room space [-1,1], z from -1 (window) to +1 (back). +// Returns bounds as (minXYZ, maxXYZ) packed; empty if no furniture. +bool furnitureBounds(vec2 roomIndex, out vec3 bmin, out vec3 bmax) { + float style = hash21(roomIndex + 3.17); + if (style < 0.18) { + // empty room + return false; + } + if (style < 0.45) { + // desk against back wall + bmin = vec3(-0.55, -1.0, 0.35); + bmax = vec3(0.55, -0.35, 0.95); + return true; + } + if (style < 0.70) { + // sofa / cabinet left + bmin = vec3(-0.95, -1.0, -0.15); + bmax = vec3(-0.35, -0.15, 0.55); + return true; + } + // tall plant / column right-center + bmin = vec3(0.35, -1.0, -0.25); + bmax = vec3(0.75, 0.35, 0.35); + return true; +} + +bool insideAABB(vec3 p, vec3 bmin, vec3 bmax) { + return all(greaterThanEqual(p, bmin)) && all(lessThanEqual(p, bmax)); +} + +bool rayMarchFurniture(inout vec3 pos, vec3 rayDir, int steps, vec3 bmin, vec3 bmax) { + // March from window (z~-1) toward back; pos is room-space. + float maxZ = bmax.z + 0.02; + float stepDepth = max(maxZ - pos.z, 0.05) / float(max(steps, 1)); + vec3 stepSize = rayDir * (stepDepth / max(abs(rayDir.z), 1e-5)); + vec3 prev = pos; + for (int i = 0; i < 32; ++i) { + if (i >= steps) break; + if (pos.z > maxZ || abs(pos.x) > 1.05 || abs(pos.y) > 1.05) break; + if (insideAABB(pos, bmin, bmax)) { + vec3 lo = prev; + vec3 hi = pos; + for (int j = 0; j < 5; ++j) { + vec3 mid = 0.5 * (lo + hi); + if (insideAABB(mid, bmin, bmax)) hi = mid; + else lo = mid; + } + pos = hi; + return true; + } + prev = pos; + pos += stepSize; + } + return false; +} + +vec3 furnitureColor(vec2 roomIndex, vec3 hit) { + float h = hash21(roomIndex + 9.1); + vec3 wood = vec3(0.45, 0.28, 0.14); + vec3 cloth = vec3(0.25 + 0.35 * h, 0.22, 0.35 + 0.2 * (1.0 - h)); + vec3 metal = vec3(0.55, 0.58, 0.62); + float band = smoothstep(-0.4, -0.2, hit.y); + return mix(wood, mix(cloth, metal, step(0.7, h)), band); +} + +void main() { + float atlasColumns = max(pc.data[0], 1.0); + float atlasRows = max(pc.data[1], 1.0); + float perspective = max(pc.data[2], 0.05); + float roomDepth = clamp(pc.data[3], 0.05, 0.95); + float randomRoom = pc.data[4]; + float randomSeed = pc.data[5]; + int fgSteps = int(clamp(pc.data[6], 1.0, 32.0)); + float showFg = pc.data[8]; + float frameWidth = clamp(pc.data[9], 0.0, 0.25); + + vec2 uv = vUV; + vec2 tileIndex = floor(uv); + vec2 localUV = fract(uv); + vec2 atlasSize = floor(max(vec2(atlasColumns, atlasRows), vec2(1.0))); + + vec2 roomIndex = tileIndex - floor(tileIndex / atlasSize) * atlasSize; + if (randomRoom > 0.5) { + vec2 rnd = hash22(tileIndex + randomSeed * 7.31); + roomIndex = floor(rnd * atlasSize); + } + + vec3 N = normalize(vNormal); + mat3 TBN = cotangentFrame(N, vWorldPos, uv); + // Article: view from camera to surface, then into tangent space. + vec3 viewWS = vWorldPos - vCameraPos; + vec3 tangentViewDir = transpose(TBN) * viewWS; // world→tangent (TBN columns are T,B,N) + + // Depth scale: remap roomDepth like the cubemap path (farFrac). + float depthScale = 1.0 / max(1.0 - roomDepth, 1e-4) - 1.0; + tangentViewDir.z *= max(depthScale, 1e-3); + + vec3 rayDir = tangentViewDir; + // March into the room (+Z of tangent space after abs on Z). + rayDir.z = max(abs(rayDir.z), 1e-4); + // Window plane at z = -1, XY in [-1,1]. + vec3 rayPos = vec3(localUV * 2.0 - 1.0, -1.0); + + vec3 hitPos = rayBoxFarHit(rayPos, rayDir); + + // Perspective correction (similar triangles): Scale = B / (B + d). + float hitDepthNorm = clamp(hitPos.z * 0.5 + 0.5, 0.0, 1.0); + float perspectiveScale = perspective / (perspective + hitDepthNorm); + vec2 roomUV = hitPos.xy * perspectiveScale; + roomUV = roomUV * 0.5 + 0.5; + roomUV = clamp(roomUV, 0.001, 0.999); + + vec2 atlasUV = (roomIndex + roomUV) / atlasSize; + vec4 bgColor = texture(albedoSampler, atlasUV); + + vec3 color = bgColor.rgb; + bool hitFg = false; + if (showFg > 0.5) { + vec3 bmin, bmax; + if (furnitureBounds(roomIndex, bmin, bmax)) { + vec3 fgPos = rayPos; + hitFg = rayMarchFurniture(fgPos, rayDir, fgSteps, bmin, bmax); + if (hitFg) { + color = furnitureColor(roomIndex, fgPos); + // Slight lighting from room-space normal of nearest furniture face. + float shade = 0.75 + 0.25 * max(-fgPos.z, 0.0); + color *= shade; + } + } + } + + // Window frame (mullions) in local UV. + float fx = min(localUV.x, 1.0 - localUV.x); + float fy = min(localUV.y, 1.0 - localUV.y); + float frame = 1.0 - smoothstep(frameWidth * 0.55, frameWidth, min(fx, fy)); + float mullionX = 1.0 - smoothstep(frameWidth * 0.2, frameWidth * 0.45, abs(localUV.x - 0.5)); + float mullionY = 1.0 - smoothstep(frameWidth * 0.2, frameWidth * 0.45, abs(localUV.y - 0.5)); + float mullion = max(mullionX, mullionY) * 0.85; + float frameMask = max(frame, mullion); + vec3 frameCol = vec3(0.08, 0.09, 0.11); + color = mix(color, frameCol, frameMask); + + // Grazing-angle glass fresnel (FH4 tip from the article lineage). + vec3 viewDir = normalize(vCameraPos - vWorldPos); + float ndotv = clamp(dot(N, viewDir), 0.0, 1.0); + float fresnel = pow(1.0 - ndotv, 3.0); + vec3 sky = vec3(0.55, 0.68, 0.85); + color = mix(color, sky, fresnel * 0.35 * (1.0 - frameMask)); + + // Mild exterior lighting on the facade plane. + vec3 L = normalize(ubo.lightDirIntensity.xyz); + float diff = 0.55 + 0.45 * max(dot(N, L), 0.0); + color *= mix(1.0, diff, 0.25); + color += ubo.ambient.rgb * 0.08; + + outColor = vec4(color * vTint.rgb, vTint.a); +}