Neon trails in the dark

This sketch transforms an uploaded image into a cartoon-style rendering, then animates glowing neon worm trails that automatically seek and flow through the dark areas of the image. The trails fade out after lingering, creating a mesmerizing light-painting effect against a dark background.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make trails glow even more — Lower WORM_THICKNESS to 0.5 so trails are thin and delicate, letting overlapping trails blend together more visibly.
  2. Create a worm explosion — Increase WORM_TRAIL_RATE to 30 so hundreds of worms spawn every frame, filling the image with dense, chaotic trails.
  3. Make trails last much longer — Double both fade timings so worms persist for 4 seconds and take 2 seconds to fade, creating slower, more dramatic disappearances.
  4. Worms become picky about darkness — Lower DARK_AREA_THRESHOLD to 30 so worms only spawn in the very darkest areas, creating sparser, more sparse trails.
  5. Make worms slower and smoother — Lower WORM_MAX_SPEED to 1 and WORM_MAX_FORCE to 0.05, creating smooth, meandering movement instead of jittery darting.
  6. Bolder cartoon edges — Lower EDGE_THRESHOLD to 20 so the cartoon effect detects subtle color shifts, creating thinner but more numerous black lines.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a stunning visual effect by combining image processing with autonomous animation. When you upload a photo, the code converts it into a cartoon-style image with bold black edges and posterized colors. Then, hundreds of glowing neon worm trails spawn in the dark areas and autonomously seek out even darker pixels, leaving smooth, fading luminous paths behind them. The effect is hypnotic and deeply customizable—every parameter controls a visible aspect of the animation, from trail thickness to how aggressively worms hunt for darkness.

The code is organized into three main layers: image processing (cartoon style conversion with edge detection), the WormTrail class (an autonomous agent that steers itself toward darkness), and the main draw loop that coordinates everything. By studying it, you will learn how to load and manipulate image pixels, implement steering behaviors borrowed from nature (boids and autonomous agents), and manage hundreds of animated objects with fading lifespans. It's a masterclass in combining generative art with image analysis.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-screen canvas, sets HSB color mode for vibrant hues, and prepares an Upload button. An invisible file input waits to detect image selection.
  2. When you click Upload Image and select a photo, handleFileSelect() loads it and calls processImageToCartoon(), which detects sharp color changes (edges) and posterizes colors to create a bold cartoon effect. The processed image is stored in a p5.Graphics buffer called cartoonImg.
  3. Every frame, draw() displays the cartoon image centered on the canvas. If an image is loaded, it spawns WORM_TRAIL_RATE new worm trails per frame, either randomly in dark areas or clustered near existing worms.
  4. Each WormTrail runs update() every frame: it samples the brightness of 8 surrounding pixels and steers its head toward the darkest one. Physics are applied (acceleration becomes velocity becomes position), and the head grows the trail by prepending a new segment.
  5. The trail is displayed with curveVertex() to draw smooth curves connecting all segments, using additive blending (blendMode(ADD)) to create the glowing neon effect. Trails fade out over 1 second after 2 seconds of age.
  6. Worms that wander into bright areas instantly die. Over time, dead trails are removed from the array, keeping performance stable.

🎓 Concepts You'll Learn

Image processing and pixel manipulationEdge detection algorithmColor posterization (cartoon effect)Autonomous steering behaviorsVector-based physics simulationParticle systems with lifespansAdditive blending for glow effectsBrightness-based pathfinding

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes the canvas, sets color modes, creates UI elements, and prepares the off-screen graphics buffer. This is where all one-time initialization happens.

function setup() {
  createCanvas(windowWidth, windowHeight);
  colorMode(HSB, 360, 100, 100, 100);
  angleMode(DEGREES);
  noStroke();

  uploadButton = createButton('Upload Image');
  uploadButton.position(10, 10);
  uploadButton.mousePressed(triggerFileInput);

  fileInput = createFileInput(handleFileSelect);
  fileInput.hide();

  cartoonImg = createGraphics(width, height);
  cartoonImg.colorMode(HSB, 360, 100, 100, 100);
  cartoonImg.noStroke();
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

function-call Canvas and color mode initialization createCanvas(windowWidth, windowHeight); colorMode(HSB, 360, 100, 100, 100);

Creates a full-screen canvas and switches to HSB (Hue, Saturation, Brightness) mode so worm colors are vibrant and easy to control

function-call UI button setup uploadButton = createButton('Upload Image'); uploadButton.position(10, 10); uploadButton.mousePressed(triggerFileInput);

Creates a green button in the top-left corner that triggers the hidden file input when clicked

function-call Graphics buffer for cartoon image cartoonImg = createGraphics(width, height);

Creates an off-screen drawing surface where the cartoon-processed image will be stored

createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window, making the neon trails fill the whole screen
colorMode(HSB, 360, 100, 100, 100);
Switches to HSB (Hue-Saturation-Brightness) color mode, where hue is 0-360 degrees. This makes it easy to pick random vibrant neon colors by just varying hue
angleMode(DEGREES);
Makes all angle measurements use degrees (0-360) instead of radians, making the code more intuitive
noStroke();
Disables outlines on shapes drawn afterward, so only fills are visible
uploadButton = createButton('Upload Image');
Creates a clickable button labeled 'Upload Image' for the user to select a photo
uploadButton.position(10, 10);
Positions the button 10 pixels from the left and top edges of the screen
uploadButton.mousePressed(triggerFileInput);
Tells the button to call triggerFileInput() when clicked, which opens the file browser
fileInput = createFileInput(handleFileSelect);
Creates a hidden file input element that triggers handleFileSelect() when a file is chosen
fileInput.hide();
Hides the default p5.js file input UI so only our styled Upload button is visible
cartoonImg = createGraphics(width, height);
Creates an off-screen graphics buffer the same size as the canvas where the processed cartoon image will be drawn
cartoonImg.colorMode(HSB, 360, 100, 100, 100);
The graphics buffer also uses HSB mode so colors are consistent

draw()

draw() is the animation heartbeat. It runs 60 times per second, clearing the canvas, spawning new worms, updating all physics and positions, and rendering everything. The worm spawning logic decides whether worms appear randomly or cluster together. The loop that updates and removes worms must iterate backward to avoid skipping indices when splicing.

function draw() {
  background(0);

  if (cartoonReady) {
    image(cartoonImg, width / 2 - img.width / 2, height / 2 - img.height / 2);

    for (let i = 0; i < WORM_TRAIL_RATE; i++) {
      let startPos = null;
      if (wormTrails.length === 0) {
        startPos = findDarkPixelPosition();
      } else {
        let existingWorm = random(wormTrails);
        if (existingWorm.segments.length > 0) {
          let segmentToFollow = existingWorm.segments[floor(random(existingWorm.segments.length))];
          let offsetX = random(-15, 15);
          let offsetY = random(-15, 15);
          let potentialSpawnX = segmentToFollow.x + offsetX;
          let potentialSpawnY = segmentToFollow.y + offsetY;

          if (getPixelBrightness(potentialSpawnX, potentialSpawnY) <= DARK_AREA_THRESHOLD) {
            startPos = createVector(potentialSpawnX, potentialSpawnY);
          } else {
            startPos = findDarkPixelPosition();
          }
        }
      }

      if (startPos) {
        wormTrails.push(new WormTrail(startPos.x, startPos.y));
      }
    }
    
    for (let i = wormTrails.length - 1; i >= 0; i--) {
      wormTrails[i].update();
      wormTrails[i].display();
      if (wormTrails[i].isDead()) {
        wormTrails.splice(i, 1);
      }
    }
    blendMode(BLEND);
  } else {
    fill(255);
    textSize(24);
    textAlign(CENTER, CENTER);
    text("Upload an image to transform it into cartoon style and see glowing worm trails!", width / 2, height / 2);
  }
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

for-loop Worm spawning logic for (let i = 0; i < WORM_TRAIL_RATE; i++) { ... }

Creates multiple new worms each frame, either randomly in dark areas or clustered near existing worms for crowding effect

for-loop Update and display all worms for (let i = wormTrails.length - 1; i >= 0; i--) { ... }

Iterates backward through the worm array to safely update, display, and remove dead worms

conditional No image message } else { fill(255); textSize(24); textAlign(CENTER, CENTER); text(...); }

Displays helpful text if no image has been uploaded yet

background(0);
Fills the entire canvas with black (0 brightness in HSB mode), erasing all trails from the previous frame so they don't accumulate
if (cartoonReady) {
Only run the animation if an image has been successfully loaded and processed
image(cartoonImg, width / 2 - img.width / 2, height / 2 - img.height / 2);
Draws the cartoon-processed image centered on the canvas (the calculation centers it by subtracting half its width and height from the center point)
if (wormTrails.length === 0) {
If there are no worms yet, spawn the first batch completely randomly in dark areas
let existingWorm = random(wormTrails);
Pick a random worm from the array to use as a spawning point for the new worm (creates crowding)
let segmentToFollow = existingWorm.segments[floor(random(existingWorm.segments.length))];
Pick a random point along the existing worm's trail to spawn near
let offsetX = random(-15, 15);
Add a random offset of ±15 pixels to the spawn position so new worms don't spawn exactly on the old one
if (getPixelBrightness(potentialSpawnX, potentialSpawnY) <= DARK_AREA_THRESHOLD) {
Check if the potential spawn point is dark enough to be a valid starting position
for (let i = wormTrails.length - 1; i >= 0; i--) {
Loop backward through the worm array (important when deleting items, since deleting shifts indices forward)
wormTrails[i].update();
Update the worm's position by running its steering logic and physics
wormTrails[i].display();
Draw the worm's trail using smooth curves and additive blending
if (wormTrails[i].isDead()) {
Check if the worm's fade duration is complete
wormTrails.splice(i, 1);
Remove the dead worm from the array to free memory
blendMode(BLEND);
Reset blend mode from ADD back to normal BLEND mode so future drawings aren't additive

WormTrail()

The constructor is called every time a new WormTrail is created. It sets up the initial position, a random neon color, random starting velocity, and records creation time. Each worm is independent and has its own color, position, and velocity.

class WormTrail {
  constructor(x, y) {
    this.segments = [createVector(x, y)];
    this.color = color(random(360), 100, 100, 100);
    this.vx = random(-1, 1);
    this.vy = random(-1, 1);
    this.ax = 0;
    this.ay = 0;
    this.maxSpeed = WORM_MAX_SPEED;
    this.maxForce = WORM_MAX_FORCE;
    this.thickness = WORM_THICKNESS;
    this.creationTime = millis();
  }
Line-by-line explanation (7 lines)
this.segments = [createVector(x, y)];
Creates an array with one vector representing the worm's starting head position
this.color = color(random(360), 100, 100, 100);
Picks a random hue (0-360), full saturation (100), full brightness (100), and full opacity (100) to create a vibrant random neon color
this.vx = random(-1, 1);
Sets initial horizontal velocity to a random value between -1 and 1 pixels per frame
this.vy = random(-1, 1);
Sets initial vertical velocity to a random value between -1 and 1 pixels per frame
this.ax = 0;
Initializes horizontal acceleration to zero; it will be calculated by steering forces
this.ay = 0;
Initializes vertical acceleration to zero
this.creationTime = millis();
Records the current time in milliseconds so the worm can track its age for fading calculations

getPixelBrightness()

This method is crucial for the sketch. Every frame, worms sample their surroundings using this function to detect dark areas. It converts between canvas coordinates (where the worm lives) and image coordinates (where pixels are stored), then returns a brightness value 0-100. This single number drives all steering behavior.

  getPixelBrightness(x, y) {
    if (!cartoonImg || !cartoonReady) return 255;

    let cartoonXOffset = width / 2 - img.width / 2;
    let cartoonYOffset = height / 2 - img.height / 2;

    let cx = x - cartoonXOffset;
    let cy = y - cartoonYOffset;

    cx = constrain(cx, 0, cartoonImg.width - 1);
    cy = constrain(cy, 0, cartoonImg.height - 1);

    let c = cartoonImg.get(cx, cy);
    return brightness(c);
  }
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Image existence check if (!cartoonImg || !cartoonReady) return 255;

Prevents crashes by returning white (255 brightness) if no image is loaded yet

calculation Canvas to image coordinate conversion let cx = x - cartoonXOffset; let cy = y - cartoonYOffset;

Converts canvas-space coordinates to image-space coordinates by accounting for the image's centered offset

function-call Boundary constraining cx = constrain(cx, 0, cartoonImg.width - 1); cy = constrain(cy, 0, cartoonImg.height - 1);

Ensures the pixel coordinates stay within the valid range of the image

getPixelBrightness(x, y) {
A method of WormTrail that samples the brightness of a pixel at canvas coordinates (x, y)
if (!cartoonImg || !cartoonReady) return 255;
Safety check: if no image is loaded, return 255 (white/very bright) so worms won't spawn or steer
let cartoonXOffset = width / 2 - img.width / 2;
Calculate how far the cartoon image is shifted horizontally from the canvas center
let cartoonYOffset = height / 2 - img.height / 2;
Calculate how far the cartoon image is shifted vertically from the canvas center
let cx = x - cartoonXOffset;
Convert the canvas x-coordinate to the image's internal x-coordinate by subtracting the offset
let cy = y - cartoonYOffset;
Convert the canvas y-coordinate to the image's internal y-coordinate
cx = constrain(cx, 0, cartoonImg.width - 1);
Clamp the image x-coordinate to stay within valid bounds (0 to width-1) to prevent out-of-bounds errors
cy = constrain(cy, 0, cartoonImg.height - 1);
Clamp the image y-coordinate to valid bounds
let c = cartoonImg.get(cx, cy);
Fetch the color value of the pixel at the clamped image coordinates
return brightness(c);
Convert the color to brightness (0-100 in HSB mode) and return it

seekDarkness()

seekDarkness() implements a classic autonomous steering behavior inspired by boids and nature. The worm looks around itself, finds the darkest neighbor, and steers toward it. The BRIGHTNESS_DIFF_THRESHOLD constant prevents needless steering in uniformly dark areas. This method is the 'brain' of the worm—everything else is just physics.

  seekDarkness() {
    let head = this.segments[0];
    let currentBrightness = this.getPixelBrightness(head.x, head.y);
    let darkestNeighborPos = createVector(head.x, head.y);
    let minBrightness = currentBrightness;

    for (let dx = -1; dx <= 1; dx++) {
      for (let dy = -1; dy <= 1; dy++) {
        if (dx === 0 && dy === 0) continue;

        let nx = head.x + dx;
        let ny = head.y + dy;

        if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
          let neighborBrightness = this.getPixelBrightness(nx, ny);
          if (neighborBrightness < minBrightness) {
            minBrightness = neighborBrightness;
            darkestNeighborPos.set(nx, ny);
          }
        }
      }
    }

    let desired;
    if (minBrightness < currentBrightness - BRIGHTNESS_DIFF_THRESHOLD) {
      desired = p5.Vector.sub(darkestNeighborPos, head);
    } else {
      desired = createVector(this.vx, this.vy);
      desired.rotate(random(-10, 10));
    }
    desired.setMag(this.maxSpeed);

    let steer = p5.Vector.sub(desired, createVector(this.vx, this.vy));
    steer.limit(this.maxForce);
    this.ax = steer.x;
    this.ay = steer.y;
  }
Line-by-line explanation (20 lines)

🔧 Subcomponents:

for-loop 8-neighbor brightness scan for (let dx = -1; dx <= 1; dx++) { for (let dy = -1; dy <= 1; dy++) { ... } }

Checks the 8 pixels surrounding the worm's head to find the darkest one

conditional Steer toward darkness or continue if (minBrightness < currentBrightness - BRIGHTNESS_DIFF_THRESHOLD) { ... } else { ... }

Decides whether to steer toward a darker neighbor or continue forward with slight random variation

calculation Physics steering force let steer = p5.Vector.sub(desired, createVector(this.vx, this.vy)); steer.limit(this.maxForce);

Calculates steering as the difference between desired velocity and current velocity, then limits it

let head = this.segments[0];
Get the worm's current head position (first segment in the array)
let currentBrightness = this.getPixelBrightness(head.x, head.y);
Sample the brightness at the head's current location
let darkestNeighborPos = createVector(head.x, head.y);
Initialize a vector to track the position of the darkest neighbor (starts as current position)
let minBrightness = currentBrightness;
Track the lowest brightness found so far (starts as current brightness)
for (let dx = -1; dx <= 1; dx++) {
Loop through x offsets: -1 (left), 0 (center), 1 (right)
for (let dy = -1; dy <= 1; dy++) {
Loop through y offsets: -1 (up), 0 (center), 1 (down), creating a 3×3 grid
if (dx === 0 && dy === 0) continue;
Skip the center pixel (the head itself) because we want to move if a neighbor is darker
let nx = head.x + dx;
Calculate the neighbor's x-coordinate
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
Check that the neighbor is within canvas bounds before sampling it
let neighborBrightness = this.getPixelBrightness(nx, ny);
Sample the brightness at the neighbor's location
if (neighborBrightness < minBrightness) {
If this neighbor is darker than the darkest found so far, update the record
if (minBrightness < currentBrightness - BRIGHTNESS_DIFF_THRESHOLD) {
Only steer if a neighbor is significantly darker (more than BRIGHTNESS_DIFF_THRESHOLD points), to reduce jitter in uniform areas
desired = p5.Vector.sub(darkestNeighborPos, head);
Create a vector pointing from the head toward the darkest neighbor
desired = createVector(this.vx, this.vy);
If no significantly darker neighbor is found, continue in the current direction
desired.rotate(random(-10, 10));
Add a small random rotation (±10 degrees) to the forward direction to encourage exploration and avoid loops
desired.setMag(this.maxSpeed);
Scale the desired vector to have a magnitude equal to maxSpeed, normalizing the steering command
let steer = p5.Vector.sub(desired, createVector(this.vx, this.vy));
Calculate steering as the difference between desired velocity and actual velocity
steer.limit(this.maxForce);
Cap the steering force so worms can't turn too sharply and maintain smooth movement
this.ax = steer.x;
Store the steering force's x-component as horizontal acceleration
this.ay = steer.y;
Store the steering force's y-component as vertical acceleration

update()

update() is the physics engine of each worm. It checks for death in bright areas, calculates steering, integrates acceleration into velocity, updates the head position, and prunes the trail to stay below WORM_MAX_SEGMENTS. This method runs every frame and is what makes the worms actually move and animate.

  update() {
    if (this.segments.length > 0 && this.getPixelBrightness(this.segments[0].x, this.segments[0].y) > BRIGHT_AREA_DEATH_THRESHOLD) {
      this.creationTime = millis() - FADE_START_TIME - FADE_DURATION - 1;
      return;
    }

    this.seekDarkness();
    this.vx += this.ax;
    this.vy += this.ay;

    this.vx = constrain(this.vx, -this.maxSpeed, this.maxSpeed);
    this.vy = constrain(this.vy, -this.maxSpeed, this.maxSpeed);

    let newHead = this.segments[0].copy();
    newHead.x += this.vx;
    newHead.y += this.vy;
    this.segments.unshift(newHead);

    if (this.segments.length > WORM_MAX_SEGMENTS) {
      this.segments.pop();
    }
  }
Line-by-line explanation (15 lines)

🔧 Subcomponents:

conditional Bright area death trigger if (this.segments.length > 0 && this.getPixelBrightness(...) > BRIGHT_AREA_DEATH_THRESHOLD) { ... }

Instantly kills the worm if it wanders into a bright area (e.g., a light part of the image)

calculation Velocity from acceleration this.vx += this.ax; this.vy += this.ay;

Integrates the steering acceleration into velocity using basic Euler integration

function-call Speed limiting this.vx = constrain(this.vx, -this.maxSpeed, this.maxSpeed);

Caps velocity so the worm never moves faster than maxSpeed

calculation Head position update let newHead = this.segments[0].copy(); newHead.x += this.vx; newHead.y += this.vy; this.segments.unshift(newHead);

Moves the head forward by velocity and adds it to the front of the segment array

conditional Trail length limiting if (this.segments.length > WORM_MAX_SEGMENTS) { this.segments.pop(); }

Removes the oldest segment if the trail gets too long, keeping memory use constant

update() {
Called every frame to move the worm, grow its trail, and handle its lifecycle
if (this.segments.length > 0 && this.getPixelBrightness(this.segments[0].x, this.segments[0].y) > BRIGHT_AREA_DEATH_THRESHOLD) {
Check if the worm's head is in a bright area (above BRIGHT_AREA_DEATH_THRESHOLD, default 85)
this.creationTime = millis() - FADE_START_TIME - FADE_DURATION - 1;
Instantly 'age' the worm to completion by setting its creation time far in the past, so it will be marked as dead
return;
Exit the update function early, skipping all movement for this frame
this.seekDarkness();
Calculate steering forces based on surrounding pixel brightness
this.vx += this.ax;
Add horizontal acceleration to horizontal velocity (Newton's second law: F = ma)
this.vy += this.ay;
Add vertical acceleration to vertical velocity
this.vx = constrain(this.vx, -this.maxSpeed, this.maxSpeed);
Clamp horizontal velocity to stay between -maxSpeed and +maxSpeed
this.vy = constrain(this.vy, -this.maxSpeed, this.maxSpeed);
Clamp vertical velocity to stay within speed limits
let newHead = this.segments[0].copy();
Copy the current head position to use as the base for the new head
newHead.x += this.vx;
Move the new head forward horizontally by the current velocity
newHead.y += this.vy;
Move the new head forward vertically by the current velocity
this.segments.unshift(newHead);
Add the new head to the front of the segments array, growing the trail
if (this.segments.length > WORM_MAX_SEGMENTS) {
Check if the trail has exceeded the maximum number of segments
this.segments.pop();
Remove the oldest segment (the tail) to keep the trail length constant and performance stable

display()

display() handles all rendering. It switches to additive blend mode for the neon glow, calculates a fading alpha based on age, and draws the trail as a smooth curve using curveVertex(). The fade timing is driven by FADE_START_TIME and FADE_DURATION constants. This is where the visual magic happens.

🔬 This code maps the worm's age into a fade progress (0-100), then subtracts it from 100 to fade out. What happens if you flip it? Try changing alpha = 100 - fadeProgress to alpha = fadeProgress so worms fade IN instead of out!

      let fadeProgress = map(currentAge, FADE_START_TIME, FADE_START_TIME + FADE_DURATION, 0, 100);
      alpha = 100 - fadeProgress;
      alpha = constrain(alpha, 0, 100);
  display() {
    blendMode(ADD);
    noFill();
    strokeWeight(this.thickness);

    let currentAge = millis() - this.creationTime;
    let alpha = 100;

    if (currentAge > FADE_START_TIME) {
      let fadeProgress = map(currentAge, FADE_START_TIME, FADE_START_TIME + FADE_DURATION, 0, 100);
      alpha = 100 - fadeProgress;
      alpha = constrain(alpha, 0, 100);
    }
    this.color.setAlpha(alpha);
    stroke(this.color);

    if (this.segments.length > 2) {
      beginShape();
      curveVertex(this.segments[0].x, this.segments[0].y);
      for (let i = 0; i < this.segments.length; i++) {
        curveVertex(this.segments[i].x, this.segments[i].y);
      }
      curveVertex(this.segments[this.segments.length - 1].x, this.segments[this.segments.length - 1].y);
      endShape();
    } else if (this.segments.length > 0) {
      for (let i = 0; i < this.segments.length; i++) {
        ellipse(this.segments[i].x, this.segments[i].y, this.thickness);
      }
    }
  }
Line-by-line explanation (22 lines)

🔧 Subcomponents:

function-call Additive blend mode blendMode(ADD); noFill(); strokeWeight(this.thickness);

Sets up additive blending for the glowing neon effect, with no fill and a stroke weight equal to the worm's thickness

conditional Fade-out logic if (currentAge > FADE_START_TIME) { ... }

After FADE_START_TIME milliseconds, gradually reduce alpha from 100 to 0 over FADE_DURATION milliseconds

conditional Smooth trail rendering if (this.segments.length > 2) { ... } else if (this.segments.length > 0) { ... }

Draws curves if the trail is long enough, otherwise draws circles for short trails

display() {
Called every frame to render the worm's trail to the canvas
blendMode(ADD);
Switch to additive blending, where colors add together instead of overwriting. This creates the glowing neon effect.
noFill();
Don't fill shapes—only stroke outlines will be drawn
strokeWeight(this.thickness);
Set the line thickness to this worm's thickness value (default 2 pixels)
let currentAge = millis() - this.creationTime;
Calculate how many milliseconds have passed since the worm was created
let alpha = 100;
Start with full opacity (100 in HSB mode)
if (currentAge > FADE_START_TIME) {
Check if the worm is old enough to start fading (default: 2000ms = 2 seconds)
let fadeProgress = map(currentAge, FADE_START_TIME, FADE_START_TIME + FADE_DURATION, 0, 100);
Map the worm's age from the start/end of the fade window to 0-100, representing fade progress
alpha = 100 - fadeProgress;
Subtract the fade progress from 100 so alpha counts down from 100 to 0
alpha = constrain(alpha, 0, 100);
Clamp alpha to 0-100 to ensure it doesn't go negative or above 100
this.color.setAlpha(alpha);
Update the worm's color to have the calculated alpha value
stroke(this.color);
Set the stroke color to the worm's neon color (with updated alpha)
if (this.segments.length > 2) {
Only use smooth curves if there are at least 3 segments
beginShape();
Start defining a shape
curveVertex(this.segments[0].x, this.segments[0].y);
Add the first segment twice for proper curve continuity
for (let i = 0; i < this.segments.length; i++) {
Loop through all segments and add each to the shape
curveVertex(this.segments[i].x, this.segments[i].y);
Add a vertex at this segment's position; together these form a smooth Catmull-Rom spline
curveVertex(this.segments[this.segments.length - 1].x, this.segments[this.segments.length - 1].y);
Add the last segment twice for proper curve continuity at the end
endShape();
Finish drawing the shape
} else if (this.segments.length > 0) {
If the trail is too short for curves, fall back to drawing individual points
for (let i = 0; i < this.segments.length; i++) {
Loop through each segment
ellipse(this.segments[i].x, this.segments[i].y, this.thickness);
Draw a circle at each segment, with diameter equal to the worm's thickness

isDead()

isDead() is a simple lifecycle check. When true, the worm is removed from the wormTrails array in the draw loop. It's called every frame for each worm, and once it returns true, that worm will be deleted to free memory.

  isDead() {
    return (millis() - this.creationTime) > (FADE_START_TIME + FADE_DURATION);
  }
Line-by-line explanation (2 lines)
isDead() {
A simple boolean method that checks if the worm's fading is complete
return (millis() - this.creationTime) > (FADE_START_TIME + FADE_DURATION);
Returns true if the worm's age in milliseconds exceeds the sum of fade start time and fade duration (i.e., the fade-out is finished)

triggerFileInput()

triggerFileInput() is a bridge between the visible Upload button and the hidden file input. When the button is clicked, this function is called, which simulates a click on the file input and opens the OS file picker.

function triggerFileInput() {
  fileInput.elt.click();
}
Line-by-line explanation (1 lines)
fileInput.elt.click();
Programmatically clicks the hidden file input element, which opens the browser's file selection dialog

handleFileSelect(file)

handleFileSelect() is the gateway function for image loading. It validates that the selected file is an image, clears old worm trails, and asynchronously loads the image. The success callback (first arrow function) processes it to cartoon style; the error callback (second arrow function) handles failures gracefully.

function handleFileSelect(file) {
  if (file.type === 'image') {
    wormTrails = [];
    img = loadImage(file.data, () => {
      processImageToCartoon();
      cartoonReady = true;
    }, (err) => {
      console.error("Error loading image:", err);
      img = null;
      cartoonReady = false;
    });
  } else {
    img = null;
    cartoonReady = false;
  }
}
Line-by-line explanation (13 lines)

🔧 Subcomponents:

conditional Image validation if (file.type === 'image') { ... }

Checks if the selected file is an image before attempting to load it

function-call Asynchronous image loading img = loadImage(file.data, () => { ... }, (err) => { ... });

Loads the image asynchronously and handles both success and error cases

function handleFileSelect(file) {
Called when a file is selected from the file input dialog
if (file.type === 'image') {
Check if the selected file is actually an image (e.g., JPEG, PNG, etc.)
wormTrails = [];
Clear all existing worm trails so they don't linger when a new image is loaded
img = loadImage(file.data, () => {
Asynchronously load the image from the file's data; the arrow function is called when loading succeeds
processImageToCartoon();
Once the image is loaded, process it into cartoon style
cartoonReady = true;
Set the flag to indicate that the cartoon image is ready for display and analysis
}, (err) => {
This second arrow function is called if image loading fails
console.error("Error loading image:", err);
Log the error to the browser console for debugging
img = null;
Clear the image variable if loading failed
cartoonReady = false;
Ensure the flag is false so the animation doesn't run without a valid image
} else {
If the user selected a non-image file
img = null;
Clear any existing image
cartoonReady = false;
Disable the animation

findDarkPixelPosition(maxAttempts)

findDarkPixelPosition() is a helper that spawns worms randomly in dark areas by brute-force searching. It picks random coordinates within the image bounds up to 100 times, checking brightness until it finds a dark-enough pixel. This ensures worms always start in the image's shadowy regions.

function findDarkPixelPosition(maxAttempts = 100) {
  if (!cartoonImg || !cartoonReady) return null;

  let cartoonXOffset = width / 2 - img.width / 2;
  let cartoonYOffset = height / 2 - img.height / 2;

  for (let i = 0; i < maxAttempts; i++) {
    let randX = random(cartoonXOffset, cartoonXOffset + img.width);
    let randY = random(cartoonYOffset, cartoonYOffset + img.height);
    if (getPixelBrightness(randX, randY) <= DARK_AREA_THRESHOLD) {
      return createVector(randX, randY);
    }
  }
  return null;
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

for-loop Random dark pixel search for (let i = 0; i < maxAttempts; i++) { ... }

Attempts up to maxAttempts times to find a random pixel that is dark enough to spawn a worm

function findDarkPixelPosition(maxAttempts = 100) {
A global function that finds a random position in a dark area. maxAttempts defaults to 100 if not specified.
if (!cartoonImg || !cartoonReady) return null;
Safety check: if no image is loaded yet, return null so no worms spawn
let cartoonXOffset = width / 2 - img.width / 2;
Calculate the horizontal offset of the cartoon image on the canvas
let cartoonYOffset = height / 2 - img.height / 2;
Calculate the vertical offset of the cartoon image
for (let i = 0; i < maxAttempts; i++) {
Try up to maxAttempts times to find a dark pixel
let randX = random(cartoonXOffset, cartoonXOffset + img.width);
Pick a random x-coordinate within the cartoon image's horizontal bounds
let randY = random(cartoonYOffset, cartoonYOffset + img.height);
Pick a random y-coordinate within the cartoon image's vertical bounds
if (getPixelBrightness(randX, randY) <= DARK_AREA_THRESHOLD) {
Check if this random pixel is dark enough (brightness ≤ DARK_AREA_THRESHOLD)
return createVector(randX, randY);
If it is dark, return a vector at that position and stop searching
return null;
If no dark pixel was found after maxAttempts iterations, return null to signal failure

getPixelBrightness(x, y) [global]

This is the global version of the WormTrail.getPixelBrightness() method. Both do the same job: sample a pixel from the cartoon image and return its brightness. The global version is available everywhere; the method is encapsulated within the class.

function getPixelBrightness(x, y) {
  if (!cartoonImg || !cartoonReady) return 255;

  let cartoonXOffset = width / 2 - img.width / 2;
  let cartoonYOffset = height / 2 - img.height / 2;

  let cx = x - cartoonXOffset;
  let cy = y - cartoonYOffset;

  cx = constrain(cx, 0, cartoonImg.width - 1);
  cy = constrain(cy, 0, cartoonImg.height - 1);

  let c = cartoonImg.get(cx, cy);
  return brightness(c);
}
Line-by-line explanation (10 lines)
function getPixelBrightness(x, y) {
A global version of the WormTrail method; used by findDarkPixelPosition and other code
if (!cartoonImg || !cartoonReady) return 255;
Return white (255) if no image is loaded
let cartoonXOffset = width / 2 - img.width / 2;
Calculate the cartoon image's horizontal offset on the canvas
let cartoonYOffset = height / 2 - img.height / 2;
Calculate the cartoon image's vertical offset
let cx = x - cartoonXOffset;
Convert canvas x-coordinate to image-space x-coordinate
let cy = y - cartoonYOffset;
Convert canvas y-coordinate to image-space y-coordinate
cx = constrain(cx, 0, cartoonImg.width - 1);
Clamp image x to valid bounds
cy = constrain(cy, 0, cartoonImg.height - 1);
Clamp image y to valid bounds
let c = cartoonImg.get(cx, cy);
Fetch the color at the clamped image coordinates
return brightness(c);
Return the brightness value (0-100)

processImageToCartoon()

processImageToCartoon() is the core image processing engine. It downsamples large images for speed, then pixel-by-pixel detects edges by comparing color differences with neighbors, and posterizes non-edge colors by reducing them to discrete levels. Edge pixels become black; others become flat colors. This is classic 1980s-style cel shading.

🔬 These three lines quantize colors to create the cartoon effect. COLOR_LEVELS (default 8) controls how many color values exist. What happens if you change COLOR_LEVELS to 2? You'd get pure black, red, green, blue, cyan, magenta, yellow, white (8 colors for RGB channels—binary). Try 16 for smoother gradation.

        r = floor(r / (255 / COLOR_LEVELS)) * (255 / COLOR_LEVELS);
        g = floor(g / (255 / COLOR_LEVELS)) * (255 / COLOR_LEVELS);
        b = floor(b / (255 / COLOR_LEVELS)) * (255 / COLOR_LEVELS);
function processImageToCartoon() {
  if (!img) return;

  let w = img.width;
  let h = img.height;
  if (w > MAX_IMG_SIZE || h > MAX_IMG_SIZE) {
    if (w > h) {
      img.resize(MAX_IMG_SIZE, 0);
    } else {
      img.resize(0, MAX_IMG_SIZE);
    }
  }

  cartoonImg.clear();
  img.loadPixels();
  
  cartoonImg.loadPixels();
  cartoonImg.blendMode(BLEND);

  for (let y = 0; y < img.height; y++) {
    for (let x = 0; x < img.width; x++) {
      let index = (x + y * img.width) * 4;
      let r = img.pixels[index];
      let g = img.pixels[index + 1];
      let b = img.pixels[index + 2];
      let a = img.pixels[index + 3];

      if (a === 0) {
        let cartoonIndex = (x + y * width) * 4;
        cartoonImg.pixels[cartoonIndex + 3] = 0;
        continue;
      }

      let isEdge = false;
      if (x < img.width - 1) {
        let rightIndex = (x + 1 + y * img.width) * 4;
        let diffR = abs(r - img.pixels[rightIndex]);
        let diffG = abs(g - img.pixels[rightIndex + 1]);
        let diffB = abs(b - img.pixels[rightIndex + 2]);
        if (diffR + diffG + diffB > EDGE_THRESHOLD) isEdge = true;
      }
      if (y < img.height - 1) {
        let bottomIndex = (x + (y + 1) * img.width) * 4;
        let diffR = abs(r - img.pixels[bottomIndex]);
        let diffG = abs(g - img.pixels[bottomIndex + 1]);
        let diffB = abs(b - img.pixels[bottomIndex + 2]);
        if (diffR + diffG + diffB > EDGE_THRESHOLD) isEdge = true;
      }

      let pixelColor;
      if (isEdge) {
        pixelColor = color(0);
      } else {
        r = floor(r / (255 / COLOR_LEVELS)) * (255 / COLOR_LEVELS);
        g = floor(g / (255 / COLOR_LEVELS)) * (255 / COLOR_LEVELS);
        b = floor(b / (255 / COLOR_LEVELS)) * (255 / COLOR_LEVELS);
        pixelColor = color(r, g, b);
      }

      cartoonImg.set(x, y, pixelColor);
    }
  }
  cartoonImg.updatePixels();
  img.updatePixels();
}
Line-by-line explanation (48 lines)

🔧 Subcomponents:

conditional Image downsampling if (w > MAX_IMG_SIZE || h > MAX_IMG_SIZE) { ... }

Resizes large images to MAX_IMG_SIZE (600px) to keep processing fast

conditional Edge detection if (x < img.width - 1) { ... } if (y < img.height - 1) { ... }

Checks if a pixel has a large color difference with its right or bottom neighbor; if so, marks it as an edge

calculation Color level reduction r = floor(r / (255 / COLOR_LEVELS)) * (255 / COLOR_LEVELS);

Reduces the number of color values to create a flat, cartoony look

function processImageToCartoon() {
Transforms the uploaded image into a cartoon-style rendering with bold edges and flat colors
if (!img) return;
Exit early if no image has been loaded
let w = img.width;
Store the image's width
let h = img.height;
Store the image's height
if (w > MAX_IMG_SIZE || h > MAX_IMG_SIZE) {
Check if either dimension exceeds MAX_IMG_SIZE (600 pixels)
if (w > h) {
If width is larger than height, resize by width
img.resize(MAX_IMG_SIZE, 0);
Resize the image to MAX_IMG_SIZE width; the 0 maintains aspect ratio
} else {
Otherwise, resize by height
img.resize(0, MAX_IMG_SIZE);
Resize the image to MAX_IMG_SIZE height; the 0 maintains aspect ratio
cartoonImg.clear();
Clear the graphics buffer that will hold the cartoon image
img.loadPixels();
Load the original image's pixel array into memory
cartoonImg.loadPixels();
Load the cartoon image's pixel array for modification
cartoonImg.blendMode(BLEND);
Use normal blend mode when drawing the cartoon image (not additive)
for (let y = 0; y < img.height; y++) {
Loop through each row of the image
for (let x = 0; x < img.width; x++) {
Loop through each column in the row (each pixel)
let index = (x + y * img.width) * 4;
Calculate the array index for this pixel (multiply by 4 because each pixel is RGBA: 4 values)
let r = img.pixels[index];
Extract the red channel (0-255)
let g = img.pixels[index + 1];
Extract the green channel (0-255)
let b = img.pixels[index + 2];
Extract the blue channel (0-255)
let a = img.pixels[index + 3];
Extract the alpha (transparency) channel (0-255)
if (a === 0) {
If the pixel is fully transparent
let cartoonIndex = (x + y * width) * 4;
Calculate the index in the cartoon buffer
cartoonImg.pixels[cartoonIndex + 3] = 0;
Set the cartoon pixel's alpha to 0 (keep it transparent)
continue;
Skip the rest of the loop for this pixel
let isEdge = false;
Initialize a flag for whether this pixel is part of an edge
if (x < img.width - 1) {
If not on the right edge of the image
let rightIndex = (x + 1 + y * img.width) * 4;
Calculate the array index of the pixel to the right
let diffR = abs(r - img.pixels[rightIndex]);
Calculate the absolute difference in the red channel between this pixel and its right neighbor
let diffG = abs(g - img.pixels[rightIndex + 1]);
Calculate the green channel difference
let diffB = abs(b - img.pixels[rightIndex + 2]);
Calculate the blue channel difference
if (diffR + diffG + diffB > EDGE_THRESHOLD) isEdge = true;
If the sum of all three color differences exceeds EDGE_THRESHOLD, mark this as an edge
if (y < img.height - 1) {
If not on the bottom edge of the image
let bottomIndex = (x + (y + 1) * img.width) * 4;
Calculate the array index of the pixel below
let diffR = abs(r - img.pixels[bottomIndex]);
Calculate the red channel difference with the pixel below
let diffG = abs(g - img.pixels[bottomIndex + 1]);
Calculate the green channel difference
let diffB = abs(b - img.pixels[bottomIndex + 2]);
Calculate the blue channel difference
if (diffR + diffG + diffB > EDGE_THRESHOLD) isEdge = true;
If the sum exceeds the threshold, this is an edge
let pixelColor;
Declare a variable for the cartoon pixel's color
if (isEdge) {
If this pixel is an edge
pixelColor = color(0);
Make it black (the cartoon effect uses black lines)
} else {
If this pixel is not an edge
r = floor(r / (255 / COLOR_LEVELS)) * (255 / COLOR_LEVELS);
Quantize the red channel: divide by the step size, round down, then multiply back to get discrete color levels
g = floor(g / (255 / COLOR_LEVELS)) * (255 / COLOR_LEVELS);
Quantize the green channel
b = floor(b / (255 / COLOR_LEVELS)) * (255 / COLOR_LEVELS);
Quantize the blue channel
pixelColor = color(r, g, b);
Create a color from the quantized RGB values
cartoonImg.set(x, y, pixelColor);
Write the cartoon pixel color to the cartoon image buffer
cartoonImg.updatePixels();
Apply all pixel modifications to the cartoon image
img.updatePixels();
Apply any pixel modifications to the original image (though it shouldn't be modified here)

windowResized()

windowResized() is called automatically every time the browser window resizes. It resizes the canvas and recreates the cartoon graphics buffer, then reprocesses any loaded image. This keeps the sketch responsive to window resizing. Note: it contains two unused variables (maxPatternRadius and ringStepSize) that are remnants from older code.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  maxPatternRadius = min(width, height) * 0.45;
  ringStepSize = maxPatternRadius / numRings;

  if (cartoonImg) {
    cartoonImg.remove();
    cartoonImg = createGraphics(width, height);
    cartoonImg.colorMode(HSB, 360, 100, 100, 100);
    cartoonImg.noStroke();
    if (img) {
      processImageToCartoon();
    }
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

function-call Canvas and graphics buffer resize resizeCanvas(windowWidth, windowHeight); if (cartoonImg) { cartoonImg.remove(); cartoonImg = createGraphics(width, height); }

Resizes the main canvas and the cartoon graphics buffer to match the new window size

conditional Image reprocessing on resize if (img) { processImageToCartoon(); }

If an image is loaded, reprocess it to fit the new canvas size

function windowResized() {
Called automatically by p5.js whenever the browser window is resized
resizeCanvas(windowWidth, windowHeight);
Resize the main drawing canvas to match the new window dimensions
maxPatternRadius = min(width, height) * 0.45;
Recalculate maxPatternRadius based on the new canvas size (these variables are remnants from old code; not used in this sketch)
ringStepSize = maxPatternRadius / numRings;
Recalculate ringStepSize (also unused; left over from old code)
if (cartoonImg) {
Check if the cartoon graphics buffer exists
cartoonImg.remove();
Delete the old graphics buffer to free memory
cartoonImg = createGraphics(width, height);
Create a new graphics buffer with the new canvas dimensions
cartoonImg.colorMode(HSB, 360, 100, 100, 100);
Set the new buffer's color mode to HSB
cartoonImg.noStroke();
Disable strokes on the new buffer
if (img) {
If an image is currently loaded
processImageToCartoon();
Reprocess it to the new canvas size

📦 Key Variables

uploadButton p5.Renderer (HTML button)

Stores a reference to the 'Upload Image' button so it can be positioned and styled

let uploadButton;
fileInput p5.Renderer (HTML file input)

Stores a reference to the hidden file input element used to select an image from the user's computer

let fileInput;
img p5.Image

Stores the original uploaded image; null if no image is loaded

let img = null;
cartoonImg p5.Graphics

An off-screen graphics buffer that stores the cartoon-processed version of the image. Worms sample brightness from this buffer.

let cartoonImg = null;
cartoonReady boolean

A flag indicating whether the cartoon image has finished processing and is ready to display

let cartoonReady = false;
wormTrails array of WormTrail objects

Stores all active worm trails. New worms are pushed; dead worms are removed.

let wormTrails = [];
WORM_TRAIL_RATE number (constant)

How many new worms spawn per frame; controls the density of trails

const WORM_TRAIL_RATE = 5;
WORM_MAX_SEGMENTS number (constant)

Maximum length of each worm trail in segments; controls how long each trail appears

const WORM_MAX_SEGMENTS = 200;
WORM_THICKNESS number (constant)

Stroke weight in pixels for drawing worm trails; controls glow appearance

const WORM_THICKNESS = 2;
WORM_MAX_SPEED number (constant)

Maximum velocity magnitude for worm movement; controls how fast worms move

const WORM_MAX_SPEED = 3;
WORM_MAX_FORCE number (constant)

Maximum steering force; controls how sharply worms can turn

const WORM_MAX_FORCE = 0.2;
FADE_START_TIME number (constant, milliseconds)

How long a worm lives before fading begins (2000ms = 2 seconds)

const FADE_START_TIME = 2000;
FADE_DURATION number (constant, milliseconds)

How long the fade-out animation takes (1000ms = 1 second)

const FADE_DURATION = 1000;
DARK_AREA_THRESHOLD number (constant, 0-100 brightness)

Maximum brightness for a pixel to be considered 'dark' and a valid worm spawn point

const DARK_AREA_THRESHOLD = 60;
BRIGHT_AREA_DEATH_THRESHOLD number (constant, 0-100 brightness)

Minimum brightness that kills a worm; worms die if they enter bright areas

const BRIGHT_AREA_DEATH_THRESHOLD = 85;
BRIGHTNESS_DIFF_THRESHOLD number (constant, 0-100 brightness)

How much darker a neighbor must be than current position to trigger strong steering; prevents jitter

const BRIGHTNESS_DIFF_THRESHOLD = 5;
EDGE_THRESHOLD number (constant)

Sensitivity of edge detection in cartoon processing; higher = fewer, thicker edges

const EDGE_THRESHOLD = 50;
COLOR_LEVELS number (constant)

Number of color levels per channel in cartoon posterization (e.g., 8 creates 8^3 = 512 total colors)

const COLOR_LEVELS = 8;
MAX_IMG_SIZE number (constant, pixels)

Maximum dimension for uploaded images; larger images are downsampled for speed

const MAX_IMG_SIZE = 600;

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

BUG windowResized()

The function references undefined variables maxPatternRadius and numRings that don't exist in this sketch. They are left over from a different sketch.

💡 Delete lines 'maxPatternRadius = min(width, height) * 0.45;' and 'ringStepSize = maxPatternRadius / numRings;' since they serve no purpose here.

PERFORMANCE draw() worm spawning

When many worms exist, spawning near random existing worms can be expensive if most spawn attempts fail (calling findDarkPixelPosition repeatedly).

💡 Track valid dark regions or use a grid-based spatial index to cache dark pixel locations, avoiding repeated brightness sampling.

PERFORMANCE processImageToCartoon()

The function iterates every pixel twice (once for edge detection, once for output), and calls getPixelBrightness() many times per pixel. This is slow for large images.

💡 Combine the edge detection and posterization into a single loop, and precompute brightness values to reduce function call overhead.

STYLE WormTrail class methods

The getPixelBrightness() method is defined both inside the class and as a global function, creating duplicate code.

💡 Remove the global getPixelBrightness() and make the class method static, or have the global function delegate to the class method to avoid redundancy.

FEATURE Entire sketch

When a worm dies (enters a bright area), it instantly fades. There's no visual warning or transition.

💡 Add a brief red flash or shrinking animation when a worm dies to make the death more visible and satisfying.

🔄 Code Flow

Code flow showing setup, draw, wormtrail, getpixelbrightness, seekdarkness, update, display, isdead, triggerfileinput, handlefileselect, finddarkpixelposition, getpixelbrightnessglobal, processimagetocarton, windowresized

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> canvas-setup[Canvas Setup] setup --> button-creation[Button Creation] setup --> graphics-buffer[Graphics Buffer] setup --> draw[draw loop] click setup href "#fn-setup" click canvas-setup href "#sub-canvas-setup" click button-creation href "#sub-button-creation" click graphics-buffer href "#sub-graphics-buffer" draw --> spawn-worms[Spawn Worms] draw --> update-display-loop[Update & Display Loop] draw --> fallback-message[Fallback Message] draw --> safety-check[Safety Check] click draw href "#fn-draw" click spawn-worms href "#sub-spawn-worms" click update-display-loop href "#sub-update-display-loop" click fallback-message href "#sub-fallback-message" click safety-check href "#sub-safety-check" spawn-worms --> random-search-loop[Random Search Loop] random-search-loop --> neighbor-scan[Neighbor Scan] neighbor-scan --> steering-decision[Steering Decision] steering-decision --> steering-force-calc[Steering Force Calculation] steering-decision --> bright-death-check[Bright Area Death Check] click random-search-loop href "#sub-random-search-loop" click neighbor-scan href "#sub-neighbor-scan" click steering-decision href "#sub-steering-decision" click steering-force-calc href "#sub-steering-force-calc" click bright-death-check href "#sub-bright-death-check" update-display-loop --> update[Update] update --> head-movement[Head Movement] update --> trail-pruning[Trail Pruning] update --> velocity-clamping[Velocity Clamping] update --> physics-integration[Physics Integration] click update href "#fn-update" click head-movement href "#sub-head-movement" click trail-pruning href "#sub-trail-pruning" click velocity-clamping href "#sub-velocity-clamping" click physics-integration href "#sub-physics-integration" update-display-loop --> display[Display] display --> blend-setup[Blend Setup] display --> fade-calculation[Fade Calculation] display --> curve-drawing[Curve Drawing] click display href "#fn-display" click blend-setup href "#sub-blend-setup" click fade-calculation href "#sub-fade-calculation" click curve-drawing href "#sub-curve-drawing" handlefileselect[Handle File Select] --> file-type-check[File Type Check] file-type-check --> image-load-callback[Image Load Callback] click handlefileselect href "#fn-handlefileselect" click file-type-check href "#sub-file-type-check" click image-load-callback href "#sub-image-load-callback" windowresized[Window Resized] --> canvas-resize[Canvas Resize] canvas-resize --> reprocess-image[Reprocess Image] click windowresized href "#fn-windowresized" click canvas-resize href "#sub-canvas-resize" click reprocess-image href "#sub-reprocess-image" wormtrail[WormTrail] --> getpixelbrightness[getPixelBrightness] getpixelbrightness --> coordinate-conversion[Coordinate Conversion] coordinate-conversion --> boundary-clamping[Boundary Clamping] click wormtrail href "#fn-wormtrail" click getpixelbrightness href "#fn-getpixelbrightness" click coordinate-conversion href "#sub-coordinate-conversion" click boundary-clamping href "#sub-boundary-clamping" wormtrail --> getpixelbrightnessglobal[getPixelBrightnessGlobal] click getpixelbrightnessglobal href "#fn-getpixelbrightnessglobal" wormtrail --> processimagetocarton[Process Image to Cartoon] processimagetocarton --> image-resize[Image Resize] image-resize --> edge-detection[Edge Detection] edge-detection --> color-posterization[Color Posterization] click processimagetocarton href "#fn-processimagetocarton" click image-resize href "#sub-image-resize" click edge-detection href "#sub-edge-detection" click color-posterization href "#sub-color-posterization"

❓ Frequently Asked Questions

What visual effects does the Neon Trails in the Dark sketch produce?

This sketch creates vibrant neon trails that flow through the dark areas of an uploaded photo, adding a dynamic and colorful element to the image.

How can users engage with the Neon Trails in the Dark sketch?

Users can upload their own photos to see the neon trails interactively generated based on the brightness of the image.

What creative coding concepts does this p5.js sketch illustrate?

The sketch demonstrates particle systems and dynamic visual effects, utilizing color manipulation and brightness thresholds to generate the neon trails.

Preview

Neon trails in the dark - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Neon trails in the dark - Code flow showing setup, draw, wormtrail, getpixelbrightness, seekdarkness, update, display, isdead, triggerfileinput, handlefileselect, finddarkpixelposition, getpixelbrightnessglobal, processimagetocarton, windowresized
Code Flow Diagram