02Rounded box room — a scene seen from inside
Rounding the box distance and tinting walls by the surface normal gives an interior render, as if the camera stepped inside the room.
Best forInterior previews, product viewing guides
ImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="br is-demo" id="brRoot">
<p class="br__hint">
<svg class="br__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M228.92 49.69a8 8 0 0 0-6.86-3.69H136a8 8 0 0 0 0 16h67.89l-83.6 95.47l-45-39.06a8 8 0 0 0-11.19.77l-56 64a8 8 0 1 0 11.9 10.62l50.59-57.87l45.11 39.15a8 8 0 0 0 11-.42l96-109.63a8 8 0 0 0 1.19-8.37Z" opacity=".2"/><path d="M224 104a8 8 0 0 0-8 8v78H40V40h136a8 8 0 0 0 0-16H40a16 16 0 0 0-16 16v152a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16v-80a8 8 0 0 0-8-8m-24-72a24 24 0 1 0 24 24a24 24 0 0 0-24-24m0 32a8 8 0 1 1 8-8a8 8 0 0 1-8 8"/></g></svg>
<span>The camera is inside the room — the surface normal decides which wall color you see</span>
</p>
<div class="br__field" id="brField" role="application" aria-label="Box room interior render board" tabindex="0">
<canvas class="br__cv" id="brCv"></canvas>
</div>
<div class="br__bar">
<span class="br__meta"><b class="br__val" id="brRes">56×24</b> render target · 56 steps</span>
<span class="br__meta br__meta--dim">edge rounding r=0.5</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 둥근 박스 방(02) — @keyframes 를 아예 두지 않는다(움직임은 rAF 캔버스 루프).
// 무대는 밝은 페이퍼. 실내 벽은 파스텔 민트·하늘·크림 — 어두운 실내 톤 금지(파스텔 라인).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-paper);
.br {
--br-wallA: #{mix($subject-mint, $stage-paper, 52%)}; // 앞뒤 벽 — 민트 파스텔
--br-wallB: #{mix($subject-sky, $stage-paper, 52%)}; // 좌우 옆벽 — 하늘색
--br-floor: #{mix($subject-pink, $stage-paper, 42%)}; // 바닥 — 옅은 핑크
--br-ceil: #{mix($subject-cream, $stage-paper, 82%)}; // 천장 — 크림
--br-hilite: #{$subject-cream}; // 램프 하이라이트
--br-shade: #{mix($subject-lilac, $subject-ink, 58%)}; // 코너 음영 — 라일락 진한 톤
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.br__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.br__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.br__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.br__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.br__cv { display: block; width: 100%; height: 100%; }
.br__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.br__meta { font-size: 10px; font-weight: 500; color: rgba($subject-ink, .72); }
.br__val { color: $subject-blue; font-weight: 700; }
@media (max-width: 340px) {
.br { gap: $sp-1; }
.br__field { height: 100px; }
.br__meta--dim { display: none; }
}
// reduce — rAF 없이 한 장. 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.br.is-still .br__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.br {
--br-wallA: rgb(63.4980392157%, 91.231372549%, 81.8509803922%);
--br-wallB: rgb(70.431372549%, 88.7843137255%, 100%);
--br-floor: rgb(100%, 84.0235294118%, 88.8%);
--br-ceil: rgb(100%, 97.4274509804%, 91.9607843137%);
--br-hilite: #fff7e6;
--br-shade: rgb(45.8666666667%, 39.6862745098%, 62.2823529412%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.br__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.br__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.br__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.br__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.br__cv {
display: block;
width: 100%;
height: 100%;
}
.br__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.br__meta {
font-size: 10px;
font-weight: 500;
color: rgba(23, 20, 26, 0.72);
}
.br__val {
color: #2f6df6;
font-weight: 700;
}
@media (max-width: 340px) {
.br {
gap: 4px;
}
.br__field {
height: 100px;
}
.br__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.br.is-still .br__field {
cursor: default !important;
}
}// 둥근 박스 방(02) — 박스 SDF 에 모서리 둥글기를 더하고, 카메라를 박스 **안쪽**에 둔다.
// 물체를 바라보던 marcher 를 뒤집으면 실내 렌더가 된다: 안쪽에서는 -map() 을 전진하고
// (경계를 향해), 닿은 벽의 법선으로 네 벽의 색을 나눈다. 법선이 카메라를 향하는 축이 곧 벽의 방향.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var RT_W = 56; // render target 폭 — 내부 씬은 면이 커 스텝당 이동이 길어 가볍다
var STEPS = 56; // 최대 전진 스텝 수
var HIT_EPS = 0.01; // 벽 도달 판정 거리(월드 단위)
var ROUND = 0.5; // 모서리 둥글기(sdRoundBox 의 r)
var LOOP_MS = 2000; // 미리보기 가상 포인터 주기
// ────────────────────────────────────────────────────
var root = document.getElementById('brRoot');
var field = document.getElementById('brField');
var cv = document.getElementById('brCv');
var ctx = cv.getContext('2d');
var resOut = document.getElementById('brRes');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
if (s.charAt(0) === '#') {
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var p = s.replace(/[^\d,.]/g, '').split(',');
return [Number(p[0]) || 0, Number(p[1]) || 0, Number(p[2]) || 0];
}
var C_WALL_A = tokRGB('--br-wallA'); // 앞벽(카메라 뒤쪽 축)
var C_WALL_B = tokRGB('--br-wallB'); // 옆벽
var C_FLOOR = tokRGB('--br-floor');
var C_CEIL = tokRGB('--br-ceil');
var C_HILITE = tokRGB('--br-hilite');
var C_SHADE = tokRGB('--br-shade');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var buf, bctx, img;
var heading = 0.4; // 편요(라디안) — 방 안에서 돌아보는 각
var pitch = 0.08; // 상하각(라디안)
var px01 = 0.5, py01 = 0.5;
var manual = false;
var raf = 0, t0 = -1;
// 실내 거리 — 카메라가 박스 안에 있을 때 둥근 벽까지의 거리.
// 코어(반폭 - 둥글기)의 면까지 여유 거리가 곧 안전 이동 거리다
function distToWall(px, py, pz) {
var hx = 3.0 - ROUND, hy = 1.5 - ROUND, hz = 3.0 - ROUND; // 둥글기를 뺀 코어 반폭
var dx = hx - Math.abs(px), dy = hy - Math.abs(py), dz = hz - Math.abs(pz);
return Math.min(dx, Math.min(dy, dz));
}
// 법선(전방 차분) — 경계 함수의 기울기. 내부에서 바깥을 향한다
function normalAt(px, py, pz) {
var e = 0.002;
var nx = distToWall(px + e, py, pz) - distToWall(px - e, py, pz);
var ny = distToWall(px, py + e, pz) - distToWall(px, py - e, pz);
var nz = distToWall(px, py, pz + e) - distToWall(px, py, pz - e);
var l = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
return [nx / l, ny / l, nz / l];
}
function clamp255(v) { return v > 255 ? 255 : (v < 0 ? 0 : v); }
function draw(tSec) {
if (!img) return;
var data = img.data;
var bw = buf.width, bh = buf.height;
// 카메라 — 방 중심 (0, 0.88, 0) 근처에서 제자리 돌기.
// 눈 높이는 코어 반높이(1.5-0.5=1.0) 안쪽이어야 첫 스텝부터 벽에 즉시 닿지 않는다
var EYE_Y = 0.88;
var ch = Math.cos(heading), sh = Math.sin(heading);
var cp = Math.cos(pitch), sp = Math.sin(pitch);
var ex = -sh * 1.1, ey = EYE_Y, ez = -ch * 1.1; // 중심에서 1.1 떨어져 선다
// 시선 전방·오른쪽·위
var fx = sh * cp, fy = sp, fz = ch * cp;
var rx = -fz, rz = fx;
var rl = Math.sqrt(rx * rx + rz * rz) || 1;
rx /= rl; rz /= rl;
var ux = -rz * fy, uy = rz * fx - rx * fz, uz = rx * fy;
// 천장 램프 — 도는 빛 위치. 원운동 반경을 크게 해 벽 밝기 변화가 측정 문턱을 넘게 한다
var lampR = 0.85;
var lampX = Math.cos(tSec * 0.9) * lampR;
var lampZ = Math.sin(tSec * 0.9) * lampR;
var lampY = 1.28;
var i = 0;
for (var y = 0; y < bh; y++) {
var vy = (1 - 2 * (y + 0.5) / bh) * 0.52;
for (var x = 0; x < bw; x++) {
var vx = (2 * (x + 0.5) / bw - 1) * 0.82;
var dx = fx + rx * vx + ux * vy;
var dy = fy + uy * vy;
var dz = fz + rz * vx + uz * vy;
var dl = Math.sqrt(dx * dx + dy * dy + dz * dz);
dx /= dl; dy /= dl; dz /= dl;
// ── march 루프(실내판) — 경계까지의 거리만큼 안쪽에서 전진 ──
var t = 0.06, hit = 0, px = ex, py = ey, pz = ez;
for (var s = 0; s < STEPS; s++) {
px = ex + dx * t; py = ey + dy * t; pz = ez + dz * t;
var d = distToWall(px, py, pz);
if (d < HIT_EPS) { hit = 1; break; } // 벽에 도달
t += d;
if (t > 9.0) break;
}
var r, g, b;
if (hit) {
var n = normalAt(px, py, pz);
// 벽 색 나눔 — 법선의 주축이 어느 벽인지 말해준다
var ax = Math.abs(n[0]), ay = Math.abs(n[1]), az = Math.abs(n[2]);
var base;
if (ay >= ax && ay >= az) {
base = n[1] > 0 ? C_CEIL : C_FLOOR; // 위·아래
} else if (ax >= az) {
base = C_WALL_B; // 좌우 옆벽
} else {
base = C_WALL_A; // 앞뒤 벽
}
// 램프 거리 조명 — 램프에 가까운 벽이 밝다
var ldx = px - lampX, ldy = py - lampY, ldz = pz - lampZ;
var ld = Math.sqrt(ldx * ldx + ldy * ldy + ldz * ldz);
var atten = 2.6 / (0.4 + ld * ld * 0.42);
// 램프를 향하는 법선 성분(란버트 근사) — 전방 차분이라 부호는 벽 바깥쪽
var toLx = -ldx / ld, toLy = -ldy / ld, toLz = -ldz / ld;
var lam = Math.max(0, -(n[0] * toLx + n[1] * toLy + n[2] * toLz));
var lit = 0.3 + Math.min(1.05, atten * (0.35 + 0.65 * lam));
r = base[0] * lit + C_SHADE[0] * 0.14;
g = base[1] * lit + C_SHADE[1] * 0.14;
b = base[2] * lit + C_SHADE[2] * 0.14;
} else {
// 벽을 못 찾은 픽셀은 없어야 하지만, 안전하게 그림자색으로
r = C_SHADE[0]; g = C_SHADE[1]; b = C_SHADE[2];
}
data[i] = clamp255(r);
data[i + 1] = clamp255(g);
data[i + 2] = clamp255(b);
data[i + 3] = 255;
i += 4;
}
}
bctx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.clearRect(0, 0, W, H);
ctx.drawImage(buf, 0, 0, W, H);
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
var bh = Math.max(12, Math.round(RT_W * H / W));
buf = document.createElement('canvas');
buf.width = RT_W; buf.height = bh;
bctx = buf.getContext('2d');
img = bctx.createImageData(RT_W, bh);
resOut.textContent = RT_W + '×' + bh;
}
function aim() {
return [(px01 - 0.5) * 2.6, 0.08 + (py01 - 0.5) * 0.5]; // heading ±1.3 · pitch -0.17..0.33
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
if (!manual) {
var ph = ((now - t0) % LOOP_MS) / LOOP_MS * TAU;
px01 = 0.5 + 0.4 * Math.sin(ph);
py01 = 0.5 + 0.34 * Math.sin(2 * ph + 0.7);
}
var a = aim();
heading += (a[0] - heading) * 0.1;
pitch += (a[1] - pitch) * 0.1;
draw(t);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) { var a1 = aim(); heading = a1[0]; pitch = a1[1]; draw(0.6); }
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) { var a2 = aim(); heading = a2[0]; pitch = a2[1]; draw(0.6); }
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(0.6); });
fit();
if (reduce) {
// reduce — 코너가 읽히는 각도의 한 장만
heading = 0.78; pitch = 0.1;
draw(0.6);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// BoxRoundRoom — vanilla 판(run/312/demo/02-rm-box-round-room/index.html) 의 React 포트.
// 박스 SDF 에 모서리 둥글기를 더하고, 카메라를 박스 안쪽에 둔다. 안쪽에서는 경계까지의
// 거리를 전진하고, 닿은 벽의 법선으로 네 벽의 색을 나눈다. 천장 램프가 원운동하며 벽 밝기가 흐른다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BoxRoundRoom.module.scss";
export type BoxRoundRoomProps = {
duration?: string;
easing?: string;
color?: string;
renderWidth?: number; // 렌더 타깃 폭(버퍼 픽셀)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Size = { w: number; h: number };
const TAU = Math.PI * 2;
const FIELD_LABEL = "박스 방 실내 렌더 실험판";
const STILL_T = 0.6;
const ROUND = 0.5; // 모서리 둥글기(sdRoundBox 의 r)
const ROOM_HX = 3.0, ROOM_HY = 1.5, ROOM_HZ = 3.0; // 방 반폭
// 실내 거리 — 카메라가 박스 안에 있을 때 둥근 벽까지의 거리.
// 코어(반폭 - 둥글기)의 면까지 여유 거리가 곧 안전 이동 거리다
function distToWall(px: number, py: number, pz: number): number {
const hx = ROOM_HX - ROUND, hy = ROOM_HY - ROUND, hz = ROOM_HZ - ROUND;
const dx = hx - Math.abs(px), dy = hy - Math.abs(py), dz = hz - Math.abs(pz);
return Math.min(dx, Math.min(dy, dz));
}
// 법선(전방 차분) — 경계 함수의 기울기. 내부에서 바깥을 향한다
function normalAt(px: number, py: number, pz: number): RGB {
const e = 0.002;
const nx = distToWall(px + e, py, pz) - distToWall(px - e, py, pz);
const ny = distToWall(px, py + e, pz) - distToWall(px, py - e, pz);
const nz = distToWall(px, py, pz + e) - distToWall(px, py, pz - e);
const l = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
return [nx / l, ny / l, nz / l];
}
function clamp255(v: number): number { return v > 255 ? 255 : (v < 0 ? 0 : v); }
export default function BoxRoundRoom({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#4cd4a6",
renderWidth = 56,
}: BoxRoundRoomProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const bufRef = useRef<HTMLCanvasElement | null>(null);
const bctxRef = useRef<CanvasRenderingContext2D | null>(null);
const imgRef = useRef<ImageData | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<Size>;
const colRef = useRef<{ wallA: RGB; wallB: RGB; floor: RGB; ceil: RGB; shade: RGB } | null>(null);
const rtRef = useRef(renderWidth) as Box<number>;
const headingRef = useRef(0.4) as Box<number>;
const pitchRef = useRef(0.08) as Box<number>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const [res, setRes] = useState("56×24");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
if (s.charAt(0) === "#") {
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
const p = s.replace(/[^\d,.]/g, "").split(",");
return [Number(p[0]) || 0, Number(p[1]) || 0, Number(p[2]) || 0];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const size = sizeRef.current;
const r = field.getBoundingClientRect();
size.w = r.width; size.h = r.height;
cv.width = Math.round(size.w); cv.height = Math.round(size.h);
ctxRef.current = ctx;
const bh = Math.max(12, Math.round(rtRef.current * size.h / size.w));
const buf = document.createElement("canvas");
buf.width = rtRef.current; buf.height = bh;
const bctx = buf.getContext("2d");
if (!bctx) return;
bufRef.current = buf; bctxRef.current = bctx;
imgRef.current = bctx.createImageData(rtRef.current, bh);
setRes(rtRef.current + "×" + bh);
}
function draw(tSec: number): void {
const img = imgRef.current, bctx = bctxRef.current, buf = bufRef.current, ctx = ctxRef.current;
const col = colRef.current;
if (!img || !bctx || !buf || !ctx || !col) return;
const data = img.data;
const size = sizeRef.current;
const bw = buf.width, bh = buf.height;
const STEPS = 56, HIT_EPS = 0.01;
// 카메라 — 방 중심 (0, 0.88, 0) 근처에서 제자리 돌기.
// 눈 높이는 코어 반높이(1.5-0.5=1.0) 안쪽이어야 첫 스텝부터 벽에 즉시 닿지 않는다
const EYE_Y = 0.88;
const heading = headingRef.current, pitch = pitchRef.current;
const ch = Math.cos(heading), sh = Math.sin(heading);
const cp = Math.cos(pitch), sp = Math.sin(pitch);
const ex = -sh * 1.1, ey = EYE_Y, ez = -ch * 1.1;
const fx = sh * cp, fy = sp, fz = ch * cp;
let rx = -fz, rz = fx;
const rl = Math.sqrt(rx * rx + rz * rz) || 1;
rx /= rl; rz /= rl;
const ux = -rz * fy, uy = rz * fx - rx * fz, uz = rx * fy;
// 천장 램프 — 도는 빛 위치. 원운동 반경이 커야 벽 밝기 변화가 눈에 보인다
const lampR = 0.85;
const lampX = Math.cos(tSec * 0.9) * lampR;
const lampZ = Math.sin(tSec * 0.9) * lampR;
const lampY = 1.28;
let i = 0;
for (let y = 0; y < bh; y++) {
const vy = (1 - 2 * (y + 0.5) / bh) * 0.52;
for (let x = 0; x < bw; x++) {
const vx = (2 * (x + 0.5) / bw - 1) * 0.82;
let dx = fx + rx * vx + ux * vy;
let dy = fy + uy * vy;
let dz = fz + rz * vx + uz * vy;
const dl = Math.sqrt(dx * dx + dy * dy + dz * dz);
dx /= dl; dy /= dl; dz /= dl;
// ── march 루프(실내판) — 경계까지의 거리만큼 안쪽에서 전진 ──
let t = 0.06, hit = 0, px = ex, py = ey, pz = ez;
for (let s = 0; s < STEPS; s++) {
px = ex + dx * t; py = ey + dy * t; pz = ez + dz * t;
const d = distToWall(px, py, pz);
if (d < HIT_EPS) { hit = 1; break; } // 벽에 도달
t += d;
if (t > 9.0) break;
}
let r: number, g: number, b: number;
if (hit) {
const n = normalAt(px, py, pz);
// 벽 색 나눔 — 법선의 주축이 어느 벽인지 말해준다
const ax = Math.abs(n[0]), ay = Math.abs(n[1]), az = Math.abs(n[2]);
let base: RGB;
if (ay >= ax && ay >= az) {
base = n[1] > 0 ? col.ceil : col.floor; // 위·아래
} else if (ax >= az) {
base = col.wallB; // 좌우 옆벽
} else {
base = col.wallA; // 앞뒤 벽
}
// 램프 거리 조명 — 램프에 가까운 벽이 밝다
const ldx = px - lampX, ldy = py - lampY, ldz = pz - lampZ;
const ld = Math.sqrt(ldx * ldx + ldy * ldy + ldz * ldz);
const atten = 2.6 / (0.4 + ld * ld * 0.42);
const toLx = -ldx / ld, toLy = -ldy / ld, toLz = -ldz / ld;
const lam = Math.max(0, -(n[0] * toLx + n[1] * toLy + n[2] * toLz));
const lit = 0.3 + Math.min(1.05, atten * (0.35 + 0.65 * lam));
r = base[0] * lit + col.shade[0] * 0.14;
g = base[1] * lit + col.shade[1] * 0.14;
b = base[2] * lit + col.shade[2] * 0.14;
} else {
r = col.shade[0]; g = col.shade[1]; b = col.shade[2];
}
data[i] = clamp255(r);
data[i + 1] = clamp255(g);
data[i + 2] = clamp255(b);
data[i + 3] = 255;
i += 4;
}
}
bctx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.clearRect(0, 0, size.w, size.h);
ctx.drawImage(buf, 0, 0, size.w, size.h);
}
function aim(): [number, number] {
return [(pxRef.current - 0.5) * 2.6, 0.08 + (pyRef.current - 0.5) * 0.5];
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!manualRef.current) {
const ph = (((now - t0Ref.current) % 2000) / 2000) * TAU;
pxRef.current = 0.5 + 0.4 * Math.sin(ph);
pyRef.current = 0.5 + 0.34 * Math.sin(2 * ph + 0.7);
}
const a = aim();
headingRef.current += (a[0] - headingRef.current) * 0.1;
pitchRef.current += (a[1] - pitchRef.current) * 0.1;
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(e: PointerEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduced()) {
const a = aim();
headingRef.current = a[0]; pitchRef.current = a[1];
draw(STILL_T);
}
}
function onLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.5; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduced()) {
const a = aim();
headingRef.current = a[0]; pitchRef.current = a[1];
draw(STILL_T);
}
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
rtRef.current = renderWidth;
colRef.current = {
wallA: tokRGB(root, "--br-wallA"),
wallB: tokRGB(root, "--br-wallB"),
floor: tokRGB(root, "--br-floor"),
ceil: tokRGB(root, "--br-ceil"),
shade: tokRGB(root, "--br-shade"),
};
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 코너가 읽히는 각도의 한 장만
headingRef.current = 0.78; pitchRef.current = 0.1;
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [renderWidth]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M228.92 49.69a8 8 0 0 0-6.86-3.69H136a8 8 0 0 0 0 16h67.89l-83.6 95.47l-45-39.06a8 8 0 0 0-11.19.77l-56 64a8 8 0 1 0 11.9 10.62l50.59-57.87l45.11 39.15a8 8 0 0 0 11-.42l96-109.63a8 8 0 0 0 1.19-8.37Z" opacity=".2"/><path d="M224 104a8 8 0 0 0-8 8v78H40V40h136a8 8 0 0 0 0-16H40a16 16 0 0 0-16 16v152a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16v-80a8 8 0 0 0-8-8m-24-72a24 24 0 1 0 24 24a24 24 0 0 0-24-24m0 32a8 8 0 1 1 8-8a8 8 0 0 1-8 8"/></g></svg>
<span>카메라가 방 안에 들어가 있습니다 — 법선이 바라보는 방향으로 벽 색이 나뉩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{res}</b> 렌더 타깃 · 스텝 56</span>
<span className={styles.metaDim}>모서리 둥글기 r=0.5</span>
</div>
</div>
</main>
);
}02 Rounded box room — a scene seen from inside — Measured area 52.589% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Ray Marching Scenes in Canvas (No WebGL)
The post this demo lives in
9 Ray Marching Scenes in Canvas (No WebGL)Ray marching finds a surface by advancing a ray step by step along a signed distance function, and every scene below run See all demos in this post
If you like this effect - similar demos
Creative UI04 Coral growth — the reaction front reaches outward from a seed9 Reaction Diffusion Simulations, No Library
Creative UI09 Ecosystem scene — sand, water, fire and plants running together in one gridFalling Sand Simulator, 9 Ways (No Library)
Creative UI05 Frosted window — fractal ice creeping inward from the frame edgeSnowflake Generator, 9 Ways (DLA, No Library)