<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Javed Baloch</title>
    <description>The latest articles on Forem by Javed Baloch (@javedblch).</description>
    <link>https://forem.com/javedblch</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1047521%2F14c9f653-e7e6-4c4a-a1d2-136df15d0bba.png</url>
      <title>Forem: Javed Baloch</title>
      <link>https://forem.com/javedblch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/javedblch"/>
    <language>en</language>
    <item>
      <title>Realistic Fabric Wrinkles in Real-Time: Building Displacement Maps with Fabric.js</title>
      <dc:creator>Javed Baloch</dc:creator>
      <pubDate>Wed, 28 Jan 2026 14:45:37 +0000</pubDate>
      <link>https://forem.com/javedblch/realistic-fabric-wrinkles-in-real-time-building-displacement-maps-with-fabricjs-nch</link>
      <guid>https://forem.com/javedblch/realistic-fabric-wrinkles-in-real-time-building-displacement-maps-with-fabricjs-nch</guid>
      <description>&lt;p&gt;&lt;em&gt;How to make designs conform to t-shirt folds without Photoshop or 3D engines&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Challenge
&lt;/h2&gt;

&lt;p&gt;When you upload a design to a t-shirt mockup, it looks flat and fake. Real fabric has wrinkles, folds, and shadows. The design should bend around these contours—but how do you achieve this effect in real-time, entirely in the browser?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The answer:&lt;/strong&gt; displacement mapping, a computer graphics technique that warps images based on depth information.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is Displacement Mapping?&lt;/strong&gt;&lt;br&gt;
Displacement mapping uses a grayscale "height map" to determine how pixels should move:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;White areas (255) = peaks (pixels move outward)&lt;/li&gt;
&lt;li&gt;Black areas (0) = valleys (pixels move inward)&lt;/li&gt;
&lt;li&gt;Gray areas (128) = neutral (no movement)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When applied to a design on fabric:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Design pixels over wrinkles/folds get compressed&lt;/li&gt;
&lt;li&gt;Design pixels over stretched areas expand&lt;/li&gt;
&lt;li&gt;The result: your design appears to wrap around the fabric's topology&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The Approach: Three-Stage Pipeline
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stage 1:&lt;/strong&gt; Generate Displacement Map from Mockup&lt;br&gt;
&lt;strong&gt;Stage 2:&lt;/strong&gt; Apply Mesh-Based Pixel Displacement&lt;br&gt;
&lt;strong&gt;Stage 3:&lt;/strong&gt; Blend Shading for Realistic Lighting&lt;/p&gt;

&lt;p&gt;Let's break down each stage with code.&lt;/p&gt;
&lt;h2&gt;
  
  
  Stage 1: Extracting Depth from the Mockup
&lt;/h2&gt;

&lt;p&gt;The t-shirt mockup already contains depth information encoded in its brightness values. Dark areas = shadows/folds, light areas = highlights/peaks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const generateDisplacementMap = (mockupImg: HTMLImageElement): HTMLImageElement =&amp;gt; {
  const canvas = document.createElement("canvas");
  canvas.width = mockupImg.naturalWidth;
  canvas.height = mockupImg.naturalHeight;
  const ctx = canvas.getContext("2d")!;

  // Draw the mockup
  ctx.drawImage(mockupImg, 0, 0);
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const data = imageData.data;

  // Step 1: Find luminance range
  let minLum = 255, maxLum = 0;

  for (let i = 0; i &amp;lt; data.length; i += 4) {
    if (data[i + 3] &amp;lt; 10) continue; // Skip transparent pixels

    // Calculate perceived brightness (ITU-R BT.601 standard)
    const lum = 0.299 * data[i] +      // Red weight
                0.587 * data[i + 1] +  // Green weight (human eye most sensitive)
                0.114 * data[i + 2];   // Blue weight

    minLum = Math.min(minLum, lum);
    maxLum = Math.max(maxLum, lum);
  }

  const range = maxLum - minLum || 1;

  // Step 2: Normalize to full 0-255 range for maximum displacement contrast
  for (let i = 0; i &amp;lt; data.length; i += 4) {
    if (data[i + 3] &amp;lt; 10) {
      data[i] = data[i + 1] = data[i + 2] = 128; // Neutral gray
      continue;
    }

    const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];

    // Stretch luminance to full range
    const stretched = ((lum - minLum) / range) * 255;

    // Convert to grayscale displacement map
    data[i] = data[i + 1] = data[i + 2] = stretched;
  }

  ctx.putImageData(imageData, 0, 0);

  const resultImg = new Image();
  resultImg.src = canvas.toDataURL("image/png");
  return resultImg;
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why This Works:
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Luminance Calculation:&lt;/strong&gt; Uses scientifically accurate weights (0.299, 0.587, 0.114) because human eyes are most sensitive to green light&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalization:&lt;/strong&gt; Stretches the range to 0-255 to maximize displacement effect&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparent Handling:&lt;/strong&gt; Sets transparent areas to neutral gray (128) so they don't displace&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual Result:&lt;/strong&gt; A grayscale map where t-shirt folds are dark and flat areas are light.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stage 2: The Displacement Engine
&lt;/h2&gt;

&lt;p&gt;Now comes the complex part: using the displacement map to actually warp the design's pixels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Technique: Bilinear Interpolation&lt;/strong&gt;&lt;br&gt;
Instead of simply offsetting pixels, we use bilinear interpolation to sample colors smoothly between four neighboring pixels. This prevents jagged edges.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;private displace(
  designImage: HTMLImageElement,
  dispMap: Float32Array,      // Normalized 0-1 displacement values
  shadeMap: Float32Array,      // Normalized 0-1 shading values
  strength: number,            // How much to displace (45 = strong effect)
  blendFactor: number,         // How much shading to apply (0.6 = 60%)
  rotation: number             // Design rotation in radians
): string {

  const designWidth = dispMap.length / shadeMap.length; // Assuming square
  const designHeight = shadeMap.length / designWidth;

  // Draw design to temporary canvas
  const designCanvas = document.createElement('canvas');
  designCanvas.width = designWidth;
  designCanvas.height = designHeight;
  const designCtx = designCanvas.getContext('2d')!;
  designCtx.drawImage(designImage, 0, 0, designWidth, designHeight);
  const designData = designCtx.getImageData(0, 0, designWidth, designHeight);

  // Create output canvas
  const outputData = designCtx.createImageData(designWidth, designHeight);

  // Pre-calculate rotation matrix for performance
  const cosR = Math.cos(rotation);
  const sinR = Math.sin(rotation);
  const centerX = designWidth / 2;
  const centerY = designHeight / 2;

  for (let y = 0; y &amp;lt; designHeight; y++) {
    for (let x = 0; x &amp;lt; designWidth; x++) {
      const i = y * designWidth + x;
      const idx = i * 4;

      const alpha = designData.data[idx + 3];
      if (alpha &amp;lt; 5) {
        // Transparent pixel - skip
        outputData.data[idx + 3] = 0;
        continue;
      }

      // Get displacement value (0-1 range)
      const disp = dispMap[i];

      // Calculate displacement offset
      // Higher strength = more displacement
      const offsetX = (disp - 0.5) * strength;
      const offsetY = (disp - 0.5) * strength;

      // Apply rotation to displacement vector
      const dx = offsetX * cosR - offsetY * sinR;
      const dy = offsetX * sinR + offsetY * cosR;

      // Source coordinates (where to sample from)
      let srcX = x + dx;
      let srcY = y + dy;

      // Clamp to valid range
      srcX = Math.max(0, Math.min(designWidth - 1, srcX));
      srcY = Math.max(0, Math.min(designHeight - 1, srcY));

      // Bilinear interpolation for smooth sampling
      const x0 = Math.floor(srcX);
      const x1 = Math.min(x0 + 1, designWidth - 1);
      const y0 = Math.floor(srcY);
      const y1 = Math.min(y0 + 1, designHeight - 1);

      const xf = srcX - x0;  // Fractional part
      const yf = srcY - y0;

      // Sample four neighboring pixels
      const idx00 = (y0 * designWidth + x0) * 4;
      const idx10 = (y0 * designWidth + x1) * 4;
      const idx01 = (y1 * designWidth + x0) * 4;
      const idx11 = (y1 * designWidth + x1) * 4;

      // Interpolate each color channel
      const lerp = (a: number, b: number, t: number) =&amp;gt; a + (b - a) * t;

      const r = lerp(
        lerp(designData.data[idx00], designData.data[idx10], xf),
        lerp(designData.data[idx01], designData.data[idx11], xf),
        yf
      );
      const g = lerp(
        lerp(designData.data[idx00 + 1], designData.data[idx10 + 1], xf),
        lerp(designData.data[idx01 + 1], designData.data[idx11 + 1], xf),
        yf
      );
      const b = lerp(
        lerp(designData.data[idx00 + 2], designData.data[idx10 + 2], xf),
        lerp(designData.data[idx01 + 2], designData.data[idx11 + 2], xf),
        yf
      );

      // Apply shading from shade map
      const shade = 1 - (1 - shadeMap[i]) * blendFactor;

      // Write final pixel with shading
      outputData.data[idx] = Math.min(255, Math.max(0, Math.round(r * shade)));
      outputData.data[idx + 1] = Math.min(255, Math.max(0, Math.round(g * shade)));
      outputData.data[idx + 2] = Math.min(255, Math.max(0, Math.round(b * shade)));
      outputData.data[idx + 3] = alpha;
    }
  }

  designCtx.putImageData(outputData, 0, 0);
  return designCanvas.toDataURL('image/png');
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking Down the Math:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Displacement Offset Calculation:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const offsetX = (disp - 0.5) * strength;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;disp ranges from 0 to 1&lt;/li&gt;
&lt;li&gt;Subtracting 0.5 centers it around 0 (-0.5 to +0.5)&lt;/li&gt;
&lt;li&gt;Multiplying by strength (45) gives ±22.5 pixel displacement range&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Rotation Handling:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const dx = offsetX * cosR - offsetY * sinR;
const dy = offsetX * sinR + offsetY * cosR;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Standard 2D rotation matrix&lt;/li&gt;
&lt;li&gt;Ensures displacement direction rotates with the design&lt;/li&gt;
&lt;li&gt;Without this, rotated designs would displace in wrong directions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Bilinear Interpolation:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const r = lerp(
  lerp(designData.data[idx00], designData.data[idx10], xf),
  lerp(designData.data[idx01], designData.data[idx11], xf),
  yf
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Samples 4 pixels around the source coordinate&lt;/li&gt;
&lt;li&gt;Blends them based on fractional position (xf, yf)&lt;/li&gt;
&lt;li&gt;Result: smooth color transitions instead of blocky artifacts&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stage 3: Shading Integration
&lt;/h2&gt;

&lt;p&gt;The shade map adds realism by darkening areas in fabric folds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Apply shading from shade map
const shade = 1 - (1 - shadeMap[i]) * blendFactor;

outputData.data[idx] = Math.round(r * shade);      // Red channel
outputData.data[idx + 1] = Math.round(g * shade);  // Green channel
outputData.data[idx + 2] = Math.round(b * shade);  // Blue channel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How Shading Works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;shadeMap[i] = 0 (dark fold) → shade = 1 - (1 - 0) * 0.6 = 0.4 → pixel becomes 40% brightness&lt;/li&gt;
&lt;li&gt;shadeMap[i] = 1 (light area) → shade = 1 - (1 - 1) * 0.6 = 1.0 → full brightness&lt;/li&gt;
&lt;li&gt;blendFactor (0.6) controls intensity: higher = more dramatic shadows&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Integrating with Fabric.js
&lt;/h2&gt;

&lt;p&gt;Here's how to apply this to a Fabric.js canvas object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
const applyDisplacementToDesign = async (designObj: any) =&amp;gt; {
  const canvas = fabricCanvasRef.current;
  if (!canvas || !mockupImageRef.current) return;

  const mockupUrl = originalMockupUrlRef.current;

  // Load images
  const [designImg, mockupImg] = await Promise.all([
    loadImage(designObj._element.src),
    loadImage(mockupUrl)
  ]);

  // Generate displacement map
  const displacementMapImg = generateDisplacementMap(mockupImg);
  await new Promise(resolve =&amp;gt; {
    displacementMapImg.onload = resolve;
  });

  // Get design bounds and rotation
  const angle = designObj.angle || 0;
  const angleRad = angle * (Math.PI / 180);

  const designBounds = {
    left: designObj.left - (designObj.width * designObj.scaleX) / 2,
    top: designObj.top - (designObj.height * designObj.scaleY) / 2,
    width: designObj.width * designObj.scaleX,
    height: designObj.height * designObj.scaleY
  };

  // Apply displacement
  const renderer = getDisplacementRenderer();
  const displacedDataUrl = renderer.displaceDesignWithBounds(
    designImg,
    displacementMapImg,
    designBounds,
    mockupBounds,
    { 
      strength: 45,        // Strong displacement
      blendFactor: 0.6,    // 60% shading
      rotation: angleRad 
    }
  );

  // Update Fabric.js object
  const displacedImg = await loadImage(displacedDataUrl);
  designObj.setElement(displacedImg);
  designObj.setCoords();
  canvas.renderAll();
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Performance Optimizations
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Mobile Detection
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const isMobileDevice = window.innerWidth &amp;lt; 768;
const strength = isMobileDevice ? 0 : 45; // Disable on mobile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Blur the Displacement Map
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dispCtx.filter = 'blur(20px)';  // Smoother displacement
dispCtx.drawImage(textureCanvas, 0, 0);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reduces pixel-level noise and creates organic warping.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Only Reapply on Transform End
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;canvas.on("object:modified", async (e) =&amp;gt; {
  await applyDisplacementToDesign(e.target);
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't recalculate during drag—only when user releases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before Displacement:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flat design overlay&lt;/li&gt;
&lt;li&gt;No depth perception&lt;/li&gt;
&lt;li&gt;Looks like a sticker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;After Displacement:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Design conforms to fabric topology&lt;/li&gt;
&lt;li&gt;Shadows in folds darken the design&lt;/li&gt;
&lt;li&gt;Realistic 3D appearance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3cpjm16oe5iuwy4likjt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3cpjm16oe5iuwy4likjt.png" alt="Building Displacement Maps with Fabric.js" width="800" height="739"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~80ms processing time on modern browsers&lt;/li&gt;
&lt;li&gt;Zero server calls (all client-side)&lt;/li&gt;
&lt;li&gt;Works with rotated designs (rotation matrix integration)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Luminance extraction&lt;/strong&gt; creates the displacement map from mockup brightness&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bilinear interpolation&lt;/strong&gt; ensures smooth pixel sampling without artifacts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotation matrices&lt;/strong&gt; allow displacement to work with transformed designs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shade maps&lt;/strong&gt; add realistic lighting to complete the effect&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas API&lt;/strong&gt; makes this possible entirely in the browser—no WebGL needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This technique can be applied to any fabric mockup: hoodies, bags, hats, or even non-fabric surfaces like wood grain or textured paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Live
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Virtual Threads IO:&lt;/strong&gt; virtualthreads.io/2d-mockups&lt;/p&gt;

&lt;p&gt;Have questions about the implementation? Drop them in the comments below!&lt;/p&gt;

</description>
      <category>programming</category>
      <category>fabricjs</category>
      <category>displacement</category>
      <category>development</category>
    </item>
  </channel>
</rss>
