02角丸箱の部屋 — 内側から見る交差シーン
箱の距離関数に角丸を足し、法線で壁を色分けすると、カメラが部屋の中に入った内装レンダになります。
使いどころ内装プレビュー、製品の見え方ガイド
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>カメラは部屋の中にいます — 法線の向きで壁の色が分かれます</span>
</p>
<div class="br__field" id="brField" role="application" aria-label="箱部屋の内装レンダー実験盤" 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> レンダーターゲット・56ステップ</span>
<span class="br__meta br__meta--dim">角丸 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; // レンダーターゲット 폭 — 내부 씬은 면이 커 스텝당 이동이 길어 가볍다
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 角丸箱の部屋 — 内側から見る交差シーン — 計測 累積面積 52.589% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · レイマーチング9選 — WebGLなし、キャンバスのピクセル計算で描く3Dシーン
このデモが属する記事
レイマーチング9選 — WebGLなし、キャンバスのピクセル計算で描く3Dシーンレイマーチングとは、画面のピクセルごとに視線を1本ずつ前へ進め、数式で定義した距離関数に初めて触れる点の色を決める3Dレンダリングの手法です。WebGLなし、キャンバスのピクセル計算だけで、球からフラクタル、雲まで9つの場面をこのページで動 この記事のデモをすべて見る


