render.tpz
// 작은 레이트레이서. 고정 장면(바닥 평면 + 구 + 점광원)을 픽셀마다 광선을 쏘아 그리고,
// 결과를 회색조 SVG 격자로 낸다. 제곱근은 컴파일러 빌트인이 없어 뉴턴-랩슨으로 앱에서 구현한다.
// A tiny raytracer. A fixed scene (ground plane + sphere + point light) is traced per pixel and
// drawn as a grayscale SVG grid. The language has no sqrt builtin, so sqrt is app-level Newton.
//
// Key identifiers: 벡터=vector, 엑스/와이/제트=x/y/z, 더하기/빼기=add/sub, 스칼라=scale,
// 내적=dot, 길이=length, 정규화=normalize, 제곱근=sqrt(Newton), 구=sphere, 중심=center,
// 반지름=radius, 광선=ray, 시점=origin, 방향=dir, 구교차/평면교차=sphere/plane hit,
// 추적=trace, 명암=intensity, 그림자=shadow, 광원=light, 처리=handle, 이스케이프=escape,
// 해상도=resolution, 격자칸=cell, 클램프=clamp.
//
// 결정적이다. 같은 입력이면 인터프리터와 네이티브/wasm 빌드에서 같은 SVG가 나온다.
// Deterministic. Same input yields the same SVG across interpreter and builds.
// 안 하는 것: 메시/BVH/반사/굴절/안티에일리어싱/고해상도/실제 이미지 바이트. 저해상 미리보기다.
// Won't do: meshes, BVH, reflection, refraction, antialiasing, high resolution, real image bytes.
type 벡터 = { 엑스: float, 와이: float, 제트: float }
type 구 = { 중심: 벡터, 반지름: float }
// 닫힌 광선 명중 태그. 해상도/광원각은 입력 경계라 int로 둔다.
// Closed ray-hit tag. Resolution/light angle stay int at the input boundary.
type 명중종류 = "없음" | "구" | "바닥"
let 엡실론 = 0.0001
// 제곱근(뉴턴-랩슨, 고정 반복). 음수/0은 0. 레이트레이서 값 범위에서 충분히 수렴한다.
// Square root via Newton-Raphson, fixed iterations. Non-positive -> 0. Converges over the
// ranges this raytracer uses.
function 제곱근(값: float) -> float {
if 값 <= 0.0 { return 0.0 }
let mut 추정 = if 값 > 1.0 { 값 / 2.0 } else { 1.0 }
let mut 반복 = 0
while 반복 < 22 {
추정 = (추정 + 값 / 추정) / 2.0
반복 = 반복 + 1
}
추정
}
function 더하기(가: 벡터, 나: 벡터) -> 벡터 { { 엑스: 가.엑스 + 나.엑스, 와이: 가.와이 + 나.와이, 제트: 가.제트 + 나.제트 } }
function 빼기(가: 벡터, 나: 벡터) -> 벡터 { { 엑스: 가.엑스 - 나.엑스, 와이: 가.와이 - 나.와이, 제트: 가.제트 - 나.제트 } }
function 스칼라(가: 벡터, 배: float) -> 벡터 { { 엑스: 가.엑스 * 배, 와이: 가.와이 * 배, 제트: 가.제트 * 배 } }
function 내적(가: 벡터, 나: 벡터) -> float { 가.엑스 * 나.엑스 + 가.와이 * 나.와이 + 가.제트 * 나.제트 }
function 길이(가: 벡터) -> float { 제곱근(내적(가, 가)) }
function 정규화(가: 벡터) -> 벡터 {
let ㄹ = 길이(가)
if ㄹ < 엡실론 { 가 } else { 스칼라(가, 1.0 / ㄹ) }
}
// 광선-구 교차. 가장 가까운 양의 t를 돌려준다. 없으면 -1.
// Ray-sphere intersection. Returns the nearest positive t, or -1 if none. Dir must be normalized.
function 구교차(시점: 벡터, 방향: 벡터, 공: 구) -> float {
let 오씨 = 빼기(시점, 공.중심)
let 비 = 내적(오씨, 방향)
let 씨 = 내적(오씨, 오씨) - 공.반지름 * 공.반지름
let 판별 = 비 * 비 - 씨
if 판별 < 0.0 { return -1.0 }
let 뿌리 = 제곱근(판별)
let 가까움 = -비 - 뿌리
if 가까움 > 엡실론 { return 가까움 }
let 멈 = -비 + 뿌리
if 멈 > 엡실론 { return 멈 }
-1.0
}
// 광선-바닥평면(와이 = 0) 교차.
// Ray-plane (y = 0) intersection.
function 평면교차(시점: 벡터, 방향: 벡터) -> float {
if 절댓값(방향.와이) < 엡실론 { return -1.0 }
let ㅌ = -시점.와이 / 방향.와이
if ㅌ > 엡실론 { ㅌ } else { -1.0 }
}
function 절댓값(값: float) -> float { if 값 < 0.0 { -값 } else { 값 } }
function 최대(가: float, 나: float) -> float { if 가 > 나 { 가 } else { 나 } }
function 최소(가: float, 나: float) -> float { if 가 < 나 { 가 } else { 나 } }
// 한 광선을 추적해 회색 명암(0~1)을 돌려준다.
// Trace one ray and return a grayscale intensity in 0..1.
function 추적(시점: 벡터, 방향: 벡터, 공: 구, 광원: 벡터) -> float {
let ㄱ = 구교차(시점, 방향, 공)
let ㅍ = 평면교차(시점, 방향)
let mut 거리 = -1.0
let mut 무엇: 명중종류 = "없음"
if ㄱ > 0.0 { 거리 = ㄱ; 무엇 = "구" }
if ㅍ > 0.0 && (무엇 == "없음" || ㅍ < 거리) { 거리 = ㅍ; 무엇 = "바닥" }
match 무엇 {
case "없음" => {
// 배경: 위로 갈수록 밝은 하늘.
// Background: a sky that brightens with height.
return 0.75 + 0.2 * 최대(0.0, 방향.와이)
}
case "구" => {}
case "바닥" => {}
}
let 점 = 더하기(시점, 스칼라(방향, 거리))
let mut 법선 = { 엑스: 0.0, 와이: 1.0, 제트: 0.0 }
let mut 기본 = 0.55
match 무엇 {
case "구" => {
법선 = 정규화(빼기(점, 공.중심))
기본 = 0.85
}
case "바닥" => {
// 바닥 체커무늬로 평면감을 준다.
// A checkerboard on the floor for a sense of the plane.
let 칸엑스 = 점.엑스 - 8.0 * 토내림(점.엑스 / 8.0)
let 칸제트 = 점.제트 - 8.0 * 토내림(점.제트 / 8.0)
let 검음 = (칸엑스 < 4.0) != (칸제트 < 4.0)
기본 = if 검음 { 0.35 } else { 0.6 }
}
case "없음" => {}
}
let 빛방향 = 정규화(빼기(광원, 점))
let 람베르트 = 최대(0.0, 내적(법선, 빛방향))
// 그림자: 점에서 광원으로 가는 광선이 구에 막히면 어둡게.
// Shadow: if the ray from the point to the light is blocked by the sphere, darken.
let 그늘시점 = 더하기(점, 스칼라(법선, 엡실론 * 10.0))
let 막힘 = 구교차(그늘시점, 빛방향, 공)
let 광원거리 = 길이(빼기(광원, 점))
let mut 그림자 = 1.0
if 막힘 > 0.0 && 막힘 < 광원거리 { 그림자 = 0.3 }
let 명암 = 0.15 * 기본 + 0.85 * 기본 * 람베르트 * 그림자
최소(1.0, 최대(0.0, 명암))
}
// 정수 부분 내림(바닥). 수학 stdlib가 없어 정수 변환으로 구한다. 음수도 바닥 쪽으로.
// Floor to the integer part. No math stdlib, so derive it; negatives floor downward too.
function 토내림(값: float) -> float {
let 자름 = toFloat(토정수(값))
if 자름 > 값 { 자름 - 1.0 } else { 자름 }
}
// float -> int 절단. 큰 값은 범위로 제한한다.
// Truncate float to int, clamped to a safe range.
function 토정수(값: float) -> int {
let 흐 = if 값 < 0.0 { -값 } else { 값 }
if 흐 < 1.0 { return 0 }
// 지수로 상한을 잡고 이분 탐색(O(log)). 1씩 세면 너무 느리다.
// Bracket with doubling then binary search (O(log)); counting by 1 is too slow.
let mut 상 = 1
while toFloat(상) <= 흐 && 상 < 1073741824 { 상 = 상 * 2 }
let mut 하 = 상 / 2
while 상 - 하 > 1 {
let 중 = (하 + 상) / 2
if toFloat(중) <= 흐 { 하 = 중 } else { 상 = 중 }
}
if 값 < 0.0 { -하 } else { 하 }
}
function 이스케이프(원문: string) -> string {
let mut 결과 = ""
for 글 in 원문.scalars() {
if 글 == "&" { 결과 = 결과 + "&" }
else if 글 == "<" { 결과 = 결과 + "<" }
else if 글 == ">" { 결과 = 결과 + ">" }
else if 글 == "\"" { 결과 = 결과 + """ }
else { 결과 = 결과 + 글 }
}
결과
}
function 숫자글(글: string) -> bool { let ㅋ = 글.codePointAt(0) ?? 0; ㅋ >= 48 && ㅋ <= 57 }
function 정수인가(토: string) -> bool {
if 토 == "" { return false }
let 글들 = 토.scalars()
let mut ㅈ = 0
while ㅈ < 글들.length {
let 글 = 글들[ㅈ]
if ㅈ == 0 && 글 == "-" { ㅈ = ㅈ + 1 }
else if 숫자글(글) { ㅈ = ㅈ + 1 }
else { return false }
}
토 != "-"
}
// 0~255 회색 한 칸을 16진수 2자리로.
// One 0..255 gray channel as 2 hex digits.
function 이육(값: int) -> string {
let 글자 = "0123456789abcdef".scalars()
let mut ㄱ = 값
if ㄱ < 0 { ㄱ = 0 }
if ㄱ > 255 { ㄱ = 255 }
let 상 = ㄱ / 16
let 하 = ㄱ - 상 * 16
글자[상] + 글자[하]
}
let 최대해상 = 64
function 처리(입력: string) -> string {
let 토큰들 = filter(입력.trim().split(" "), 토 => 토 != "")
let mut 해상도 = 40
let mut 광각 = 35
if 토큰들.length >= 1 {
if !정수인가(토큰들[0]) { return "<div class=\"err\">숫자만 입력하세요: {이스케이프(토큰들[0])}</div>" }
if 토큰들[0].scalars().length > 9 { return "<div class=\"err\">숫자가 너무 큽니다: {이스케이프(토큰들[0])}</div>" }
해상도 = toInt(토큰들[0]) ?? 40
}
if 토큰들.length >= 2 {
if !정수인가(토큰들[1]) { return "<div class=\"err\">숫자만 입력하세요: {이스케이프(토큰들[1])}</div>" }
if 토큰들[1].scalars().length > 9 { return "<div class=\"err\">숫자가 너무 큽니다: {이스케이프(토큰들[1])}</div>" }
광각 = toInt(토큰들[1]) ?? 35
}
if 토큰들.length > 2 { return "<div class=\"err\">입력은 해상도와 선택 광원각 두 개입니다. 예: 44 35</div>" }
if 해상도 < 8 { 해상도 = 8 }
if 해상도 > 최대해상 { 해상도 = 최대해상 }
if 광각 < 0 { 광각 = 0 }
if 광각 > 80 { 광각 = 80 }
// 고정 장면: 바닥 와이=0, 구 중심 (0,1,0) 반지름 1, 점광원은 광각으로 좌우 이동.
// Fixed scene: floor y=0, sphere at (0,1,0) r=1, point light swept left/right by 광각.
let 공 = { 중심: { 엑스: 0.0, 와이: 1.0, 제트: 0.0 }, 반지름: 1.0 }
let 광치우침 = (toFloat(광각) - 35.0) / 12.0
let 광원 = { 엑스: -2.5 + 광치우침, 와이: 5.0, 제트: 3.0 }
let 시점 = { 엑스: 0.0, 와이: 1.6, 제트: 5.0 }
let 화면거리 = 4.0
let 반화면 = 1.7
let 칸크기 = 6
let mut 본문 = ""
let 실수해상 = toFloat(해상도)
let mut 줄 = 0
while 줄 < 해상도 {
let mut 열 = 0
while 열 < 해상도 {
// 픽셀 중심을 [-1,1]로. 와이는 위가 +.
// Pixel center to [-1,1]; y is up.
let 유 = ((toFloat(열) + 0.5) / 실수해상) * 2.0 - 1.0
let 브이 = 1.0 - ((toFloat(줄) + 0.5) / 실수해상) * 2.0
let 화면점 = { 엑스: 유 * 반화면, 와이: 시점.와이 + 브이 * 반화면, 제트: 시점.제트 - 화면거리 }
let 방향 = 정규화(빼기(화면점, 시점))
let 명암 = 추적(시점, 방향, 공, 광원)
let 회색 = 토정수(명암 * 255.0 + 0.5)
let 색 = "#" + 이육(회색) + 이육(회색) + 이육(회색)
let 엑스픽 = 열 * 칸크기
let 와이픽 = 줄 * 칸크기
본문 = 본문 + "<rect x=\"{엑스픽}\" y=\"{와이픽}\" width=\"{칸크기}\" height=\"{칸크기}\" fill=\"{색}\"/>"
열 = 열 + 1
}
줄 = 줄 + 1
}
let 변 = 해상도 * 칸크기
let 머리 = "<div class=\"meta\">해상도 {해상도}x{해상도}, 광원각 {광각}. 앱 레벨 제곱근으로 추적했습니다.</div>"
머리 + "<svg viewBox=\"0 0 {변} {변}\" width=\"{변}\" height=\"{변}\" style=\"image-rendering:pixelated;border:1px solid #000\" xmlns=\"http://www.w3.org/2000/svg\">" + 본문 + "</svg>"
}
print(처리(input()))