feat(examples): add Interior Mapping (pre-projected room atlas)

Implement the Zhihu Interior Mapping technique as a Mesh3D example:
tangent-space ray-AABB, perspective correction, runtime room atlas,
procedural foreground furniture ray-march, window frames and fresnel.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>
This commit is contained in:
Cursor Agentand西风逍遥游 committed 2026-09-12 13:14:10 +00:00
1 parent a12e24e46c
commit 4708ea0ef4
4 files changed
+605

No files matched your search

+39
View File
@@ -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` — 窗口配置
+8
View File
@@ -0,0 +1,8 @@
config = {
width = 1280
height = 720
title = "EVEngine — Interior Mapping"
debug = true
hotReload = true
modules = ["gfx", "win", "fs", "image"]
};
+317
View File
@@ -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();
};
@@ -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);
}