• About Us
  • Privacy Policy
  • Disclaimer
  • Contact Us
AimactGrow
  • Home
  • Technology
  • AI
  • SEO
  • Coding
  • Gaming
  • Cybersecurity
  • Digital marketing
No Result
View All Result
  • Home
  • Technology
  • AI
  • SEO
  • Coding
  • Gaming
  • Cybersecurity
  • Digital marketing
No Result
View All Result
AimactGrow
No Result
View All Result

Past the Luminance Ramp: A Form-Conscious ASCII Renderer in Three.js

Admin by Admin
September 4, 2026
Home Coding
Share on FacebookShare on Twitter



Editor’s Notice: As our Three.js Convention celebration continues, we’re excited to welcome Edoardo Lunardi with an interesting exploration of ASCII rendering. Going past easy luminance ramps, Edoardo reveals how shape-aware sampling, GPU-based glyph looking out, and Three.js can rework a 3D object right into a remarkably detailed, interactive ASCII print.

🥖 Pack your luggage. Paris is looking! The very first Three.js Convention is coming to Paris. Use code CODROPS for 15% off and get your ticket →

Each ASCII shader I’ve learn does the identical factor. Pattern the scene, compute luminance, index right into a ramp like .:-=+*#%@, performed. Ten traces, and it holds up proper till the article has an edge.

Put a tough diagonal throughout the body and it comes out as a staircase of # and % alternating on a hair of brightness, noise sorted by weight. The ramp solely is aware of how shiny a cell is, not the place contained in the cell that brightness sits, so a slash and a dot with the identical protection come out as the identical character. Any edge that isn’t horizontal or vertical dissolves into tone.

Recently I’ve been deep in ASCII, dithering, and retro tech aesthetics. All of them come right down to the identical constraint: a set grid and a small vocabulary of marks, the place the entire drawback is deciding which mark goes the place.

I rebuilt the Codrops mark as a strong you possibly can drag round, printed totally in ASCII on the GPU. Each character cell samples six factors inside itself and ten within the cells round it, builds a six-value form vector, then searches all 95 printable glyphs for the closest match. Each cell, each body.

The mark printed twice from the identical pose, a luminance ramp on the left and the form search on the best. The ramp turns the diagonal edge into noise sorted by brightness; the search resolves the identical edge into characters that comply with it.

Three passes, one glyph search

The renderer runs three passes per body, and splitting them is what makes the search low-cost sufficient to run in any respect.

renderer.setRenderTarget(this.#sceneTarget);
renderer.render(this.#scene, this.#digital camera);

this.#quad.materials = this.#cellMaterial;
renderer.setRenderTarget(this.#cellTarget);
renderer.render(this.#body, this.#frameCamera);

this.#quad.materials = this.#postMaterial;
renderer.setRenderTarget(null);
renderer.render(this.#body, this.#frameCamera);
The three targets for one body: the scene move with its personal lighting, the cell goal false-coloured by the glyph index every cell selected, and the ultimate print.

The scene move attracts the strong into an offscreen goal with its personal lighting. The cell move runs one fragment per character cell, picks that cell’s glyph and writes the profitable index. The submit move reads these indices again and stamps the glyph from an atlas, in no matter ink the web page resolved.

The search runs as soon as per cell, and on the base cell of 6 by 10 CSS pixels that’s as soon as per 60 pixels quite than as soon as per pixel. The cell goal is sized in cells quite than machine pixels too, so the fee holds flat because the machine ratio climbs.

Constructing the mark as actual geometry

The mark is a lens with the droplet minimize by way of it quite than a disc extruded alongside Z. The cell move reads tone, and an extrusion offers it virtually nothing to learn.

Two flat faces and a straight wall resolve to a single tone every underneath any lighting mannequin, so each cell inside a face will get the identical form vector and the mark prints as a blob with a tough define. Each faces need to curve so the tone runs throughout them.

A near-flat extrusion printed beside the domed lens, identical pose and digital camera. The flat faces resolve to a single tone every and print as a blob with an overview; the domed faces give each cell a special tone to learn.

Every face is minimize from a sphere positioned to move by way of the rim and thru the center. Two constraints, so one radius:

const DOME_RADIUS = (OUTER_RADIUS * OUTER_RADIUS + domeSag * domeSag) / (2 * domeSag);
const DOME_CENTRE = Math.sqrt(DOME_RADIUS * DOME_RADIUS - OUTER_RADIUS * OUTER_RADIUS) - RIM_DEPTH / 2;

const domeZ = (r) => Math.sqrt(Math.max(DOME_RADIUS * DOME_RADIUS - r * r, 0)) - DOME_CENTRE;

domeZ offers the peak of both face at any distance from the centre, and on the outer radius it comes out at precisely half the rim thickness.

The droplet define is a polar perform. Previous the angle the place a tangent from the apex meets the bulb, the define is the bulb’s personal arc. Earlier than it, the define is the tangent line. The form is convex with the centre inside it, so each ray leaves precisely as soon as and the perform is single valued:

perform dropletRadius(theta) {
  const dy = Math.sin(theta);
  const flip = theta - TANGENT_FROM - Math.flooring((theta - TANGENT_FROM) / TAU) * TAU;

  if (flip <= TANGENT_SPAN) {
    return TANGENT_C / (TANGENT_NX * Math.abs(Math.cos(theta)) + TANGENT_NY * dy);
  }

  const alongside = dy * BULB_Y;

  return alongside + Math.sqrt(Math.max(alongside * alongside - BULB_Y * BULB_Y + BULB_RADIUS * BULB_RADIUS, 0));
}
The define as a polar perform: the bulb’s arc, the 2 tangent traces assembly on the apex, and the tangent factors the place one palms over to the opposite. Drawn from the identical constants the geometry is constructed from.

The Math.abs on the cosine is what lets the right-hand tangent serve each side of a mirror-symmetric define. Section depend is a a number of of 4 so one step lands precisely on the apex and that nook stays sharp as a substitute of getting sanded off by the sampling.

Face normals come from the dome sphere’s personal radius by way of every level quite than from differencing neighbours, so that they’re actual. The rim and the droplet wall get a flat regular per step as a substitute, which is what retains the apex a tough nook:

const nz = (Math.abs(z) + DOME_CENTRE) * aspect;
const size = Math.hypot(x, y, nz) || 1;

return [
  [x, y, z],
  [x / length, y / length, nz / length],
];

192 segments at 22 quads every offers 4,224 quads, or 8,448 triangles, constructed as soon as right into a non-indexed BufferGeometry. The curvature is what the print is studying, so flat geometry can’t be rescued later within the shader.

Baking the glyph atlas

The atlas is rasterized within the browser at runtime, from no matter monospace face the stylesheet resolved on the canvas. 95 glyphs, house by way of tilde, drawn into a ten by 10 grid with 8 pixels of bleed round every cell so a glyph overshooting its field isn’t clipped right into a false edge.

ctx.fillStyle = "#ffffff";
ctx.textAlign = "heart";
ctx.textBaseline = "center";
ctx.font = `${weight} ${Math.flooring(Math.min(cellH * 0.92, cellW / 0.58))}px ${font}`;

for (let glyph = 0; glyph < GLYPHS.size; glyph++) {
  ctx.fillText(GLYPHS[glyph], (glyph % cols) * padW + padW / 2, Math.flooring(glyph / cols) * padH + padH / 2);
}
The baked sheet, straight off the supply canvas, with the internal cell bins stroked. The bleed round every field is what retains an overshooting glyph from being clipped right into a false edge.

The scale is the smaller of two suits, 92% of the cell peak or the cell width over 0.58, so tall glyphs and vast glyphs each land contained in the field with out measuring something per glyph.

The face is loaded with doc.fonts.load quite than awaited by way of doc.fonts.prepared, so the atlas bakes from the supposed face as a substitute of from regardless of the fallback stack resolves to first. A failed fetch nonetheless bakes, in no matter it falls again to.

Six factors as a substitute of 1 common

Every glyph will get a six-value vector describing the place its ink sits, measured at six mounted factors contained in the cell:

const INNER_SAMPLES = [
  [0.28, 0.26],
  [0.72, 0.14],
  [0.28, 0.56],
  [0.72, 0.44],
  [0.28, 0.86],
  [0.72, 0.74],
];

The proper column rides larger than the left, and that asymmetry is doing the work. A diagonal operating backside left to high proper lands on the excessive proper samples and the low left samples, which is a special signature from two stacked dots even when the whole protection is similar. Symmetrical factors would collapse each instances into the identical vector and put us again the place the ramp was.

Every worth is the protection of the glyph’s alpha channel inside a disc of radius 0.26 cell heights round its level, so a stroke passing close to a pattern nonetheless registers as a substitute of falling between faucets.

Normalized per pattern level, not globally

Normalizing the entire set towards one world peak would collapse a lot of the vocabulary. Glyphs carrying heavy whole ink would win each slot, and a flat area of the mark would map onto one or two dense characters throughout its total space.

for (let pattern = 0; pattern < INNER_SAMPLES.size; pattern++) {
  let peak = 0;

  for (let glyph = 0; glyph < depend; glyph++) {
    peak = Math.max(peak, vectors[glyph * INNER_SAMPLES.length + sample]);
  }

  if (peak > 0) {
    for (let glyph = 0; glyph < depend; glyph++) {
      vectors[glyph * INNER_SAMPLES.length + sample] /= peak;
    }
  }
}

Every of the six slots is scaled by its personal peak throughout the 95 glyphs, so a slot that no glyph fills closely nonetheless spans the complete vary, and flat tone retains spreading throughout the vocabulary as a substitute of collapsing onto a single glyph.

The cell shader does its personal normalization, and it isn’t the identical operation. It takes a single peak throughout that cell’s six values and raises every of them to a CONTRAST exponent towards it.

The atlas vectors ship as a 6 by 95 single-channel float texture, one row per glyph, learn with texelFetch so nothing is filtered on the way in which in.

The search, one fragment per cell

The cell move is the place the body time goes. Every of its sixteen pattern positions is multiple texel learn: a centre faucet plus six on a hexagonal ring, averaged:

vec4 sampleCircle(vec2 c) {
  vec2 center = cellBase + vec2(c.x, 1.0 - c.y) * uCellPx;
  float r = uCellPx.y * 0.161;
  vec4 acc = fetchTap(center);

  for (int ok = 0; ok < 6; ok++) {
    acc += fetchTap(center + RING[k] * r);
  }

  return acc / 7.0;
}

Seven faucets means a pattern measures a small disc quite than a degree, and faucets falling exterior the scene goal return zero quite than clamping to the sting.

Luminance comes out after unpremultiplying, then will get weighted again by protection:

float circleLum(vec4 acc) {
  vec3 straight = acc.rgb / max(acc.a, 1e-4);

  return clamp(dot(straight, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0) * acc.a;
}

Dividing by alpha recovers the strong’s personal shading impartial of how a lot of the disc it covers. Multiplying by alpha on the finish places the protection again into the quantity, so a cell half crammed by the silhouette returns a decrease worth than a full cell on the identical shading. The search sees the define and the shading in a single worth.

Neighbour faucets, so a cell is aware of which aspect of an edge it’s on

The ten outer faucets are what make an edge snap as a substitute of smear. A cell sitting on a boundary averages each side of it, which leaves the sting regionally low distinction in precisely the place it must be excessive.

Every internal pattern is in contrast towards the brightest neighbour mendacity within the instructions it faces, then pushed down if it loses:

float dirContrast(float worth, float ext) {
  float peak = max(worth, ext);

  if (peak < 1e-4) {
    return worth;
  }

  return pow(worth / peak, EDGE_CONTRAST) * peak;
}

v[0] = dirContrast(v[0], max(max(e[0], e[1]), max(e[2], e[4])));
v[1] = dirContrast(v[1], max(max(e[0], e[1]), max(e[3], e[5])));
// v[2] by way of v[5] comply with the identical form
The six internal samples and ten outer faucets drawn towards a 3 by 3 cell neighbourhood. The cell being solved is the centre one; each outer faucet sits inside a neighbour.

If a neighbour exterior the cell is brighter, this pattern sits on the dim aspect of an edge operating by way of the area, and the exponent widens the hole between the 2 halves of the cell. That’s the distinction between a cell resolving to a slash and the identical cell resolving to a % signal.

Then the search itself, a plain linear scan with no early exit:

int greatest = 0;
float bestD = 1e9;

for (int g = 0; g < uGlyphCount; g++) {
  float d = 0.0;

  for (int i = 0; i < 6; i++) {
    float diff = v[i] - texelFetch(tShapes, ivec2(i, g), 0).r;

    d += diff * diff;
  }

  if (d < bestD) {
    bestD = d;
    greatest = g;
  }
}

outColor = vec4(colAcc / max(alphaAcc, 1e-4), float(greatest) / 255.0);

570 subtract-square-accumulate operations per cell, towards a texture sufficiently small to sit down in cache, and 112 texture fetches on high for the sixteen pattern discs.

The winner leaves within the alpha channel as float(greatest) / 255.0. 95 glyphs match underneath 255, so an unusual 8-bit RGBA goal carries the index and there’s no want for a second attachment or a float format.

Compositing with out mip seams

The submit move has one lure in it, and something that samples an atlas per cell will hit the identical one.

Atlas UVs bounce discontinuously at each cell boundary. One cell holds a hash from the center of the sheet, the following holds an L from the nook, so the UV is a sawtooth with a tough break on every edge. Let the GPU take derivatives of that UV by itself and each bounce reads as a texture minified into nothing, so it reaches for the smallest mip and faint seam traces seem alongside the grid. Worse, whether or not they present up in any respect is dependent upon cell dimension and machine ratio, so the bug comes and goes because the structure adjustments, which is strictly what makes it simple to overlook.

vec2 atlasStep = uAtlasInner / uAtlasGrid;
float masks = textureGrad(tAtlas, atlasUv, dFdx(cellPos) * atlasStep, dFdy(cellPos) * atlasStep).a;

cellPos is steady throughout the body, so its by-product is a sane per-pixel step, and scaling by the atlas cell dimension converts it into the best by-product in atlas house.

Conserving the fee tied to the cell grid

Price here’s a perform of the cell grid, not of the canvas. The scene goal is sized at twelve pixels per cell row:

const scale = SCENE_CELL_PX / cellHeight;
const sceneWidth = Math.max(Math.spherical(width * scale), 1);
const sceneHeight = Math.max(Math.spherical(peak * scale), 1);

this.#renderer.setPixelRatio(dpr);
this.#renderer.setSize(width, peak, false);

this.#sceneTarget.setSize(sceneWidth, sceneHeight);
this.#cellTarget.setSize(cols, rows);

The cell move wants sufficient scene decision to put its sixteen pattern discs and nothing past that. Sizing the goal off the canvas would imply rendering the strong at full machine ratio and throwing virtually all of it away in averaging. Solely the submit move runs at canvas decision, and per pixel it does one texelFetch for the cell’s index plus one atlas lookup.

Cell depend is aimed toward 4,600. Previous that the cell grows and the grid stays roughly the place it’s:

const uncooked = (this.#width / CONFIG.cellW) * (this.#peak / (CONFIG.cellW * CONFIG.lineRatio));
const progress = uncooked > CONFIG.maxCells ? Math.sqrt(uncooked / CONFIG.maxCells) : 1;

this.#cellW = Math.spherical(CONFIG.cellW * progress);
this.#cellH = Math.spherical(this.#cellW * CONFIG.lineRatio);

maxCells is a goal quite than a tough ceiling. Cell dimensions get rounded to entire pixels after the expansion issue is utilized, so the true depend lands close to 4,600 and might sit above it. Progress is all the time computed from the bottom cell dimension, by no means from the earlier outcome, or a run of resizes would compound into cells the dimensions of tiles.

The loop is capped at 60fps, with slack:

const FRAME_SLACK_MS = 8;

if (dt < 1000 / CONFIG.frameHz - FRAME_SLACK_MS) {
  return;
}

A threshold of precisely 1000/60 lands on the show’s personal beat. One body arrives a fraction early, will get skipped, and the loop settles into 30fps. Eight milliseconds of slack retains the edge away from it. Easing runs on a frame-rate impartial exponential damp so the identical drag feels similar at 60 and 144Hz, and an IntersectionObserver stops the loop totally as soon as the factor leaves the viewport.

Gentle and darkish as a tone inversion

Switching theme inverts the scene tone quite than swapping a color, as a result of density means reverse issues on the 2 grounds. On paper a dense glyph reads as darkish. On a darkish floor the identical glyph reads as gentle. Preserve the tone and alter solely the ink and the mark prints as its personal destructive on one in every of them.

outColor = vec4(combine(lit, combine(vec3(0.18), vec3(1.0), 1.0 - lit), uPaper), 1.0);

The 0.18 is the lit aspect’s minimal ink. With out it, absolutely lit tone inverts to zero, the brightest area of the mark will get handed an area character, and the silhouette breaks open precisely the place the sunshine lands.

The identical pose in each themes. The scene tone inverts with the bottom and solely the ink color adjustments, so the mark reads as the identical object on each as a substitute of as its personal destructive.

The ink by no means seems within the shader in any respect. The factor reads the computed color off the canvas, paints it right into a one-pixel canvas and reads the bytes again:

ctx.fillStyle = getComputedStyle(factor).colour;
ctx.fillRect(0, 0, 1, 1);

const [r, g, b] = ctx.getImageData(0, 0, 1, 1).information;

return new Colour().setRGB(r / 255, g / 255, b / 255, LinearSRGBColorSpace);

Parsing that string can be a shedding sport. The cascade can hand down color-mix(), a relative color, oklch(), or no matter ships subsequent 12 months. Solely the browser reliably is aware of what it resolved to, and a one-pixel canvas is the most cost effective strategy to ask.

Every thing inside one customized factor

There’s no framework right here and no element tree. The web page ships a canvas inside a customized factor, and the stylesheet owns the field:


  

Three issues need to be true in CSS. A hard and fast facet ratio so the canvas isn’t a structure shift, a monospace household on the canvas as a result of the atlas bakes from it, and a colour on the factor as a result of the print is drawn in no matter ink resolves there.

There’s no loading state and no fallback. The canvas ships hidden and fades in as soon as a body lands cleanly. If WebGL is lacking, a shader is rejected, or the context is misplaced, the factor retains its field and stays empty, which is quieter than a skeleton that by no means resolves into something.

renderer.debug.onShaderError = () => {
  throw new Error("ascii-logo: shader did not compile");
};

Throwing on a shader error quite than logging it’s deliberate. A rejected shader takes the identical path as a lacking context, so there’s one failure department to purpose about as a substitute of two, and the pointer listener solely will get connected as soon as there’s one thing to show.

Diminished movement holds the body and skips the idle float, although a drag nonetheless runs, as a result of a drag is the customer’s personal doing quite than movement imposed on them. As soon as the easing settles beneath a threshold the loop stops as a substitute of repainting an unchanged body eternally.

What sampling for structure buys you

Sampling for structure turns an ASCII filter into an ASCII print. A diagonal comes out as a slash, a nook as an L, a flat face as a fair area that also varies throughout itself, and the mark holds its edges the entire approach by way of a drag. That’s the explanation to render it as a strong as a substitute of operating a filter over an image of 1.

Not one of the equipment cares that the output occurs to be characters. The offscreen goal sized off the cell grid, the glyph index driving in an 8-bit alpha channel, the neighbour faucets, the hand-computed derivatives. Swap the atlas for tiles, dominoes, or a set of hand-drawn strokes and the search doesn’t change.

Tags: ASCIILuminanceRampRendererShapeAwareThree.js
Admin

Admin

Next Post
Phishing Marketing campaign Sends Hundreds of thousands of Emails Utilizing Invisible Unicode to Evade Filters

Phishing Marketing campaign Sends Hundreds of thousands of Emails Utilizing Invisible Unicode to Evade Filters

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Recommended.

US Motion in Venezuela Provokes Cyberattack Hypothesis

US Motion in Venezuela Provokes Cyberattack Hypothesis

January 5, 2026
Meta’s shock Llama 4 drop exposes the hole between AI ambition and actuality

Meta’s shock Llama 4 drop exposes the hole between AI ambition and actuality

April 8, 2025

Trending.

Telegram ban in India sparks a rush to VPNs, rival apps

Telegram ban in India sparks a rush to VPNs, rival apps

June 19, 2026
High LLM Observability and Analysis Platforms in 2026: Langfuse, LangSmith, Braintrust, Arize, and Extra In contrast

High LLM Observability and Analysis Platforms in 2026: Langfuse, LangSmith, Braintrust, Arize, and Extra In contrast

August 9, 2026
AI & data-driven Starbucks – Deep Brew

AI & data-driven Starbucks – Deep Brew

May 18, 2026
The Full Information to EcoGPT

The Full Information to EcoGPT

June 6, 2026
Self-Coding AI: Breakthrough or Hazard?

Self-Coding AI: Breakthrough or Hazard?

July 4, 2025

AimactGrow

Welcome to AimactGrow, your ultimate source for all things technology! Our mission is to provide insightful, up-to-date content on the latest advancements in technology, coding, gaming, digital marketing, SEO, cybersecurity, and artificial intelligence (AI).

Categories

  • AI
  • Coding
  • Cybersecurity
  • Digital marketing
  • Gaming
  • SEO
  • Technology

Recent News

Valve Does Nothing As Counter-Strike 2 ‘Slurs Monitoring’ Turns Aggressive

Valve Does Nothing As Counter-Strike 2 ‘Slurs Monitoring’ Turns Aggressive

September 4, 2026
Phishing Marketing campaign Sends Hundreds of thousands of Emails Utilizing Invisible Unicode to Evade Filters

Phishing Marketing campaign Sends Hundreds of thousands of Emails Utilizing Invisible Unicode to Evade Filters

September 4, 2026
  • About Us
  • Privacy Policy
  • Disclaimer
  • Contact Us

© 2025 https://blog.aimactgrow.com/ - All Rights Reserved

No Result
View All Result
  • Home
  • Technology
  • AI
  • SEO
  • Coding
  • Gaming
  • Cybersecurity
  • Digital marketing

© 2025 https://blog.aimactgrow.com/ - All Rights Reserved