<?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>Shaders on blog</title>
    <link>https://jpcharalambosh.co/tags/shaders/</link>
    <description>Recent content in Shaders 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/shaders/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>
    <item>
      <title>p5.tree.js</title>
      <link>https://jpcharalambosh.co/posts/tree/</link>
      <pubDate>Tue, 17 Feb 2026 16:07:46 -0500</pubDate>
      <guid>https://jpcharalambosh.co/posts/tree/</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;a href=&#34;https://github.com/VisualComputing/p5.tree&#34;&gt;p5.tree&lt;/a&gt; is a render pipeline layer for &lt;a href=&#34;https://beta.p5js.org/&#34;&gt;p5.js v2&lt;/a&gt; — &lt;a href=&#34;https://en.wikipedia.org/wiki/Key_frame&#34;&gt;pose and camera interpolation&lt;/a&gt;, &lt;a href=&#34;https://learnopengl.com/Getting-started/Coordinate-Systems&#34;&gt;coordinate-space conversions&lt;/a&gt; between WORLD / EYE / SCREEN / NDC, &lt;a href=&#34;https://en.wikipedia.org/wiki/Hidden-surface_determination&#34;&gt;frustum visibility&lt;/a&gt;, HUD, &lt;a href=&#34;https://en.wikipedia.org/wiki/Multiple_Render_Targets&#34;&gt;multi-pass post-processing&lt;/a&gt;, picking, and declarative control panels. The demo below exercises all of it at once.&lt;/p&gt;
&lt;p&gt;Under the hood it&amp;rsquo;s three independent packages: a renderer-agnostic numeric core (&lt;code&gt;@nakednous/tree&lt;/code&gt; — math, spaces, keyframes, visibility), a lightweight DOM layer (&lt;code&gt;@nakednous/ui&lt;/code&gt; — sliders, transport), and a &lt;a href=&#34;https://beta.p5js.org/&#34;&gt;p5.js v2&lt;/a&gt; bridge that wires them to the canvas. The dependency direction is strict and one-way — the core knows nothing about p5 or the DOM — which is what lets the same keyframe interpolation that drives a camera path also animate any object, and lets the whole stack run headless or in a future renderer without touching the math.&lt;/p&gt;</description>
    </item>
    <item>
      <title>GPU-based Photomosaic</title>
      <link>https://jpcharalambosh.co/posts/photomosaic/</link>
      <pubDate>Sat, 02 Sep 2023 15:07:24 -0500</pubDate>
      <guid>https://jpcharalambosh.co/posts/photomosaic/</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This demo visualization shows a &lt;a href=&#34;https://en.wikipedia.org/wiki/Photographic_mosaic&#34;&gt;photomosaic (&amp;amp; videomosaic)&lt;/a&gt; implemented in hardware:&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(press &lt;code&gt;r&lt;/code&gt; to randomly pick another painting)&lt;br&gt;














&lt;iframe
  id=&#34;&#34;
  style=&#34;width:675px; height:675px&#34;
  srcdoc=&#34;
        &lt;!DOCTYPE html&gt;
        &lt;html&gt;
          &lt;head&gt;
            &lt;script src=&#39;https://cdn.jsdelivr.net/npm/p5@1.11.10/lib/p5.min.js&#39;&gt;&lt;/script&gt;
            
            &lt;script src=&#39;https://cdn.jsdelivr.net/gh/VisualComputing/p5.treegl/p5.treegl.min.js&#39;&gt;&lt;/script&gt;
            
            
            
            &lt;script src=https://cdn.jsdelivr.net/gh/objetos/p5.quadrille.js/p5.quadrille.min.js&gt;&lt;/script&gt;
            
            
            
            
            &lt;script&gt;
              
&amp;#39;use strict&amp;#39;
let palette
let fbo
let mosaic
let src
let paintings
let video_src
// ui
let resolution
let video_on
let uv
let mode

// MAX_TEXTURE_SIZE seem to be 4096 in my firefox (others don&amp;#39;t complain)
// see: https://t.ly/k7E_
// palette.width -&amp;gt; n * SAMPLE_RES
// 120 wasn&amp;#39;t disliked before
const SAMPLE_RES = navigator.userAgent.includes(&amp;#39;Firefox&amp;#39;) ? 65 : 150

function preload() {
  video_src = createVideo([&amp;#39;wagon.webm&amp;#39;])
  video_src.hide()
  mosaic = readShader(&amp;#39;mosaic.frag&amp;#39;)
  paintings = []
  for (let i = 1; i &amp;lt;= 30; i&amp;#43;&amp;#43;) {
    paintings.push(loadImage(`p${i}.jpg`))
  }
}

function setup() {
  createCanvas(650, 650, WEBGL)
  textureMode(NORMAL)
  noStroke()
  resolution = createSlider(1, 200, 40, 1)
  resolution.position(10, 35)
  resolution.style(&amp;#39;width&amp;#39;, &amp;#39;80px&amp;#39;)
  video_on = createCheckbox(&amp;#39;video&amp;#39;, false)
  video_on.style(&amp;#39;color&amp;#39;, &amp;#39;magenta&amp;#39;)
  video_on.changed(() =&amp;gt; {
    src = video_on.checked() ? video_src : random(paintings)
    video_on.checked() ? video_src.loop() : video_src.pause()
  })
  video_on.position(10, 55)
  src = random(paintings)
  uv = createCheckbox(&amp;#39;uv visualization&amp;#39;, false)
  uv.style(&amp;#39;color&amp;#39;, &amp;#39;magenta&amp;#39;)
  uv.position(10, 70)
  mode = createSelect()
  mode.position(10, 90)
  mode.option(&amp;#39;original&amp;#39;)
  mode.option(&amp;#39;keys&amp;#39;)
  mode.option(&amp;#39;symbols&amp;#39;)
  mode.selected(&amp;#39;symbols&amp;#39;)
  mode.changed(() =&amp;gt; mode.value() === &amp;#39;original&amp;#39; ? resolution.hide() : resolution.show())
  palette = createQuadrille(paintings)
  fbo = createFramebuffer({ width: SAMPLE_RES * palette.width, height: SAMPLE_RES, format: FLOAT })
  sample()
}

function sample() {
  palette.sort({ ascending: true, cellLength: SAMPLE_RES })
  fbo.begin()
  //resetShader()
  drawQuadrille(palette, { cellLength: SAMPLE_RES, outlineWeight: 0, x: -SAMPLE_RES * palette.width / 2, y: -SAMPLE_RES * palette.height / 2 })
  fbo.end()
}

function draw() {
  applyShader(mosaic, {
    uniforms: {
      source: src,
      palette: fbo.color,
      n: palette.width,
      resolution: resolution.value(),
      uv: uv.checked(),
      keys: mode.value() === &amp;#39;keys&amp;#39;,
      original: mode.value() === &amp;#39;original&amp;#39;
    }
  })
}

function keyPressed() {
  if (key === &amp;#39;r&amp;#39; &amp;amp;&amp;amp; !video_on.checked()) {
    src = random(paintings)
  }
}

            &lt;/script&gt;
          &lt;/head&gt;
          &lt;body&gt;
          &lt;/body&gt;
        &lt;/html&gt;
      &#34;
&gt;&lt;/iframe&gt;
&lt;/p&gt;</description>
    </item>
  </channel>
</rss>
