← 제니앱

레이트레이서

바닥과 구와 점광원으로 된 장면을 픽셀마다 광선을 쏘아 회색조로 그립니다. 제곱근은 빌트인 없이 앱에서 뉴턴 방법으로 구합니다.

입력은 해상도와 선택 광원각입니다. 예 40 35. 해상도는 8부터 64까지이며 저해상 미리보기입니다. 같은 입력은 항상 같은 그림을 냅니다.

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 글 == "&" { 결과 = 결과 + "&amp;" }
    else if 글 == "<" { 결과 = 결과 + "&lt;" }
    else if 글 == ">" { 결과 = 결과 + "&gt;" }
    else if 글 == "\"" { 결과 = 결과 + "&quot;" }
    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()))