<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Perspective on blog</title>
    <link>https://jpcharalambosh.co/tags/perspective/</link>
    <description>Recent content in Perspective on blog</description>
    <image>
      <title>blog</title>
      <url>https://jpcharalambosh.co/papermod-cover.png</url>
      <link>https://jpcharalambosh.co/papermod-cover.png</link>
    </image>
    <generator>Hugo</generator>
    <language>en</language>
    <lastBuildDate>Tue, 24 Mar 2026 10:28:01 -0500</lastBuildDate>
    <atom:link href="https://jpcharalambosh.co/tags/perspective/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Visualizing Perspective Transformation to NDC</title>
      <link>https://jpcharalambosh.co/posts/ndc/</link>
      <pubDate>Tue, 24 Mar 2026 10:28:01 -0500</pubDate>
      <guid>https://jpcharalambosh.co/posts/ndc/</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;a href=&#34;https://jsantell.com/3d-projection/#perspective-projection&#34;&gt;Perspective projection&lt;/a&gt; is a fundamental concept in 3D graphics. The transformation maps a view frustum — a truncated pyramid — into a cube of Normalized Device Coordinates (NDC), producing the foreshortening effect that makes 3D scenes look convincing. The sketch below morphs a set of &lt;code&gt;cajas&lt;/code&gt; (boxes) continuously from world space into NDC space, rendered from a third-person camera that also displays the frustum being transformed.&lt;/p&gt;
&lt;/blockquote&gt;
















&lt;iframe
  id=&#39;p5-0&#39;
  style=&#39;border:none; display:block; width:0; height:0;&#39;
  srcdoc=&#34;
    &lt;!DOCTYPE html&gt;
    &lt;html&gt;
      &lt;head&gt;
        &lt;style&gt;body{margin:0;padding:0;overflow:hidden}&lt;/style&gt;
        &lt;script src=&#39;https://cdn.jsdelivr.net/npm/p5@2.2.3/lib/p5.min.js&#39;&gt;&lt;/script&gt;
        
        &lt;script src=&#39;https://cdn.jsdelivr.net/npm/p5.tree/dist/p5.tree.min.js&#39;&gt;&lt;/script&gt;
        
        
        
        
        
        
        
        
        &lt;script&gt;
          
&amp;#39;use strict&amp;#39;

// NDC morph — p5.js v2 &amp;#43; p5.tree v0.0.25
// original: https://jpcharalambosh.co/posts/ndc/

let mainCam, frustumCam
let fillMorph, outlineMorph
let frustumProj       // p5.Matrix(4) — frustum projection, updated each frame
let vBuf, eBuf        // Float32Array(16) — frustum view / eye matrices
let cajas, panel

const N    = 81       // near plane
const NDC  = 0.5      // NDC morph offset
const FOVY = 0.75     // frustum vertical fov
const Z    = 158      // frustum camera distance

// ── setup ─────────────────────────────────────────────────────────────────────
function setup() {
  createCanvas(600, 400, WEBGL)
  strokeWeight(0.8)
  colorMode(RGB, 1)

  frustumProj = createMatrix(4)
  vBuf        = new Float32Array(16)
  eBuf        = new Float32Array(16)

  panel = createPanel({
    d:       { min: 0, max: 1, value: 0, step: 0.01 },
    animate: { value: true },
  }, {
    title: &amp;#39;NDC morph&amp;#39;, collapsible: true,
    labels: true, color: &amp;#39;white&amp;#39;,
    x: width - 130, y: 10,
  })

  // Morph body is identical for fill and stroke; only the struct type differs:
  //   fill   → &amp;#39;Vertex&amp;#39;        (struct: position, normal, texCoord, color)
  //   stroke → &amp;#39;StrokeVertex&amp;#39;  (struct: position, tangentIn, tangentOut, color, weight)
  const morphBody = `{
    vec4 fPos       = uViewFrustumMatrix * vec4(inputs.position, 1.0);
    vec2 xy         = -(fPos.xy / fPos.z) * (1.0 &amp;#43; ndc) * n;
    fPos.xy         = mix(fPos.xy, xy, d);
    inputs.position = (uEyeFrustumMatrix * fPos).xyz;
    return inputs;
  }`

  const morphUniforms = {
    uniforms: {
      &amp;#39;mat4 uViewFrustumMatrix&amp;#39;: () =&amp;gt; vBuf,
      &amp;#39;mat4 uEyeFrustumMatrix&amp;#39;:  () =&amp;gt; eBuf,
      &amp;#39;float d&amp;#39;:   () =&amp;gt; panel.d.value(),
      &amp;#39;float ndc&amp;#39;: () =&amp;gt; NDC,
      &amp;#39;float n&amp;#39;:   () =&amp;gt; N,
    },
  }

  fillMorph = buildColorShader({
    ...morphUniforms,
    &amp;#39;Vertex getWorldInputs&amp;#39;: `(Vertex inputs) ${morphBody}`,
  })

  outlineMorph = buildStrokeShader({
    ...morphUniforms,
    &amp;#39;StrokeVertex getWorldInputs&amp;#39;: `(StrokeVertex inputs) ${morphBody}`,
  })

  cajas = Array.from({ length: 40 }, () =&amp;gt; ({
    position: createVector(random(-50, 50), random(-50, 50), random(-25, 75)),
    size:     random(5, 20),
    col:      color(random(), random(), random()),
  }))

  mainCam = createCamera()
  mainCam.camera(
    -250.56, -257.35, 181.54,
    -249.9,  -256.75, 181.2,
    -0.57, 0.745, 0.34
  )
  frustumCam = createCamera()
  setCamera(mainCam)

  const f = 7 / 30
  ortho(-width * f, width * f, -height * f, height * f, 1, 10000)
}

// ── draw ──────────────────────────────────────────────────────────────────────
function draw() {
  background(&amp;#39;#138d75&amp;#39;)

  if (panel.animate.value()) {
    panel.d.set(map(sin(frameCount / 60), -1, 1, 0, 1))
  }

  updateFrustum()

  // grid
  push()
  rotateX(HALF_PI)
  strokeWeight(0.2)
  stroke(&amp;#39;#FFC300&amp;#39;)
  grid({ subdivisions: 20, size: 240 })
  pop()

  // single scene pass — fill, stroke, and frustum gizmo all morphed together
  push()
  shader(fillMorph)
  strokeShader(outlineMorph)
  strokeWeight(0.8)
  stroke(&amp;#39;magenta&amp;#39;)
  scene()
  fill(1, 0, 1, 0.3)
  viewFrustum({
    mat4Eye: eBuf,
    mat4Proj: frustumProj,
    bits: p5.Tree.NEAR | p5.Tree.FAR,
    viewer: () =&amp;gt; {
      push()
      resetShader()
      axes({ size: 50, bits: p5.Tree.X | p5.Tree._Y | p5.Tree._Z })
      pop()
    },
  })
  pop()
}

// ── updateFrustum ─────────────────────────────────────────────────────────────
function updateFrustum() {
  frustumCam.camera(0, 0, Z, ...p5.Tree.k, ...p5.Tree.j)
  frustumCam.mat4View(vBuf)
  frustumCam.mat4Eye(eBuf)
  const far = N * (1 &amp;#43; 2 * tan(FOVY / 2) * (1 &amp;#43; NDC))
  frustumProj.perspective(FOVY, 1, N, far)
}

// ── scene ─────────────────────────────────────────────────────────────────────
function scene() {
  cajas.forEach(({ position: pos, size, col }) =&amp;gt; {
    push()
    fill(col)
    translate(pos.x, pos.y, pos.z)
    box(size)
    pop()
  })
}

        &lt;/script&gt;
        
        &lt;script&gt;
          (function() {
            var t = setInterval(function() {
              var c = document.querySelector(&#39;canvas&#39;)
              if (c &amp;&amp; c.offsetWidth &gt; 0) {
                clearInterval(t)
                window.parent.postMessage({ type: &#39;p5resize&#39;, id: &#39;p5-0&#39;, w: c.offsetWidth, h: c.offsetHeight }, &#39;*&#39;)
              }
            }, 100)
          })()
        &lt;/script&gt;
      &lt;/head&gt;
      &lt;body&gt;&lt;/body&gt;
    &lt;/html&gt;
  &#34;
&gt;&lt;/iframe&gt;
&lt;script&gt;
window.addEventListener(&#39;message&#39;, function(e) {
  if (e.data &amp;&amp; e.data.type === &#39;p5resize&#39; &amp;&amp; e.data.id === &#39;p5-0&#39;) {
    var f = document.getElementById(&#39;p5-0&#39;)
    if (f) { f.style.width = e.data.w + &#39;px&#39;; f.style.height = e.data.h + &#39;px&#39; }
  }
})
&lt;/script&gt;



&lt;h1 id=&#34;shaders&#34;&gt;Shaders&lt;/h1&gt;
&lt;p&gt;The morph lives in a single GLSL hook injected into p5&amp;rsquo;s base shaders through &lt;a href=&#34;https://beta.p5js.org/reference/p5/buildColorShader/&#34;&gt;&lt;code&gt;buildColorShader&lt;/code&gt;&lt;/a&gt; and &lt;a href=&#34;https://beta.p5js.org/reference/p5/buildStrokeShader/&#34;&gt;&lt;code&gt;buildStrokeShader&lt;/code&gt;&lt;/a&gt; — no shader files needed.&lt;/p&gt;</description>
    </item>
  </channel>
</rss>
