A quick DEMO of Ruby-LibGD v0.2.4.

Not a tutorial, not a benchmark — just experimenting with 2D and 3D rendering in Ruby and confirming that the foundation is already mature and reliable.

Links:

# frozen_string_literal: true
require "gd"

W = 1400
H = 500

TEXT = "Ruby-LibGD v0.2.4"
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
SIZE = 72
DEPTH = 32

img =
  begin
    GD::Image.new(W, H, true)
  rescue
    GD::Image.new(W, H)
  end

# -------------------------------------------------
# 🌌 Background gradient
# -------------------------------------------------
(0...H).each do |y|
  t = y.to_f / H
  r = (20 + 40 * Math.sin(t * Math::PI)).to_i
  g = (20 + 30 * t).to_i
  b = (40 + 80 * (1 - t)).to_i
  c = GD::Color.rgb(r, g, b)
  img.line(0, y, W, y, c)
end

# -------------------------------------------------
# ✨ Stars
# -------------------------------------------------
star_colors = [
  GD::Color.rgb(255, 255, 255),
  GD::Color.rgb(255, 220, 180),
  GD::Color.rgb(180, 220, 255)
]

300.times do
  x = rand(W)
  y = rand(H)
  c = star_colors.sample
  img.filled_rectangle(x, y, x + rand(2), y + rand(2), c)
end

# -------------------------------------------------
# 📐 Measure text (YOUR API)
# -------------------------------------------------
tw, th = img.text_bbox(TEXT, font: FONT, size: SIZE)
x0 = (W - tw) / 2
y0 = (H + th) / 2

# -------------------------------------------------
# 🌈 Rainbow 3D extrusion
# -------------------------------------------------
DEPTH.downto(1) do |i|
  hue = i.to_f / DEPTH
  r = (200 + 55 * Math.sin(hue * 2 * Math::PI)).to_i
  g = (200 + 55 * Math.sin(hue * 2 * Math::PI + 2)).to_i
  b = (200 + 55 * Math.sin(hue * 2 * Math::PI + 4)).to_i

  color = GD::Color.rgb(r, g, b)

  img.text_ft(TEXT, {
    x: x0 + i * 1.4,
    y: y0 + i * 0.9,
    size: SIZE,
    font: FONT,
    color: color
  })
end

# -------------------------------------------------
# 🖤 Soft outline
# -------------------------------------------------
outline = GD::Color.rgb(20, 20, 20)
[-2, -1, 1, 2].each do |d|
  img.text_ft(TEXT, {
    x: x0 + d,
    y: y0,
    size: SIZE,
    font: FONT,
    color: outline
  })
end

# -------------------------------------------------
# 🔴 Front face (Ruby red)
# -------------------------------------------------
front = GD::Color.rgb(220, 40, 40)
img.text_ft(TEXT, {
  x: x0,
  y: y0,
  size: SIZE,
  font: FONT,
  color: front
})

img.save("ruby_libgd_festival.png")
puts "✨ ruby_libgd_festival.png generated"

SPONSOR LOGISTIC INTELLIGENCE:


# frozen_string_literal: true
require "gd"

# -----------------------
# Helpers
# -----------------------
def clamp01(x)
  return 0.0 if x < 0.0
  return 1.0 if x > 1.0
  x
end

def hsv_to_rgb(h, s, v)
  h = h % 1.0
  s = clamp01(s)
  v = clamp01(v)

  i = (h * 6.0).floor
  f = h * 6.0 - i
  p = v * (1.0 - s)
  q = v * (1.0 - f * s)
  t = v * (1.0 - (1.0 - f) * s)

  r, g, b =
    case i % 6
    when 0 then [v, t, p]
    when 1 then [q, v, p]
    when 2 then [p, v, t]
    when 3 then [p, q, v]
    when 4 then [t, p, v]
    when 5 then [v, p, q]
    end

  [(r * 255).round, (g * 255).round, (b * 255).round]
end

def rot_x(y, z, a)
  [y * Math.cos(a) - z * Math.sin(a), y * Math.sin(a) + z * Math.cos(a)]
end

def rot_y(x, z, a)
  [x * Math.cos(a) + z * Math.sin(a), -x * Math.sin(a) + z * Math.cos(a)]
end

def rot_z(x, y, a)
  [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)]
end

def set_px(img, x, y, color)
  if img.respond_to?(:set_pixel)
    img.set_pixel(x, y, color)
  elsif img.respond_to?(:pixel)
    img.pixel(x, y, color)
  else
    img[x, y] = color
  end
end

# -----------------------
# Canvas
# -----------------------
w = 900
h = 900
cx = w / 2
cy = h / 2

img =
  begin
    GD::Image.new(w, h, true)
  rescue ArgumentError
    GD::Image.new(w, h)
  end

bg = GD::Color.rgb(14, 14, 18)
if img.respond_to?(:filled_rectangle)
  img.filled_rectangle(0, 0, w - 1, h - 1, bg)
else
  (0...h).each { |yy| (0...w).each { |xx| set_px(img, xx, yy, bg) } }
end

cache = {}

# -----------------------
# Torus + Camera parameters
# -----------------------
R = 1.55
r = 0.65

ax = 0.9
ay = 0.6
az = 0.2

z_offset = 3.2
screen_scale = 260.0
fov = 2.2

lx, ly, lz = -0.35, -0.45, 0.82
llen = Math.sqrt(lx * lx + ly * ly + lz * lz)
lx /= llen; ly /= llen; lz /= llen

steps_u = 220
steps_v = 220
dot = 2

points = []

(0...steps_u).each do |iu|
  u = iu * (2.0 * Math::PI) / steps_u
  cu = Math.cos(u)
  su = Math.sin(u)

  (0...steps_v).each do |iv|
    v = iv * (2.0 * Math::PI) / steps_v
    cv = Math.cos(v)
    sv = Math.sin(v)

    x = (R + r * cu) * cv
    y = (R + r * cu) * sv
    z = r * su

    nx = cu * cv
    ny = cu * sv
    nz = su

    y, z = rot_x(y, z, ax)
    x, z = rot_y(x, z, ay)
    x, y = rot_z(x, y, az)

    ny, nz = rot_x(ny, nz, ax)
    nx, nz = rot_y(nx, nz, ay)
    nx, ny = rot_z(nx, ny, az)

    zc = z + z_offset
    p = fov / (fov + zc)

    sx = (x * p * screen_scale + cx).round
    sy = (y * p * screen_scale + cy).round

    next if sx < 0 || sy < 0 || sx >= w || sy >= h

    hue = v / (2.0 * Math::PI)
    base_r, base_g, base_b = hsv_to_rgb(hue, 0.92, 1.0)

    ndotl = nx * lx + ny * ly + nz * lz
    diff = clamp01(ndotl)
    spec = clamp01((diff ** 20) * 1.2)

    shade = 0.18 + 0.82 * diff
    rr = (base_r * shade + 255 * spec).round
    gg = (base_g * shade + 255 * spec).round
    bb = (base_b * shade + 255 * spec).round

    points << [zc, sx, sy, rr, gg, bb]
  end
end

points.sort_by!(&:first)

points.each do |_, x, y, rr, gg, bb|
  key = (rr << 16) | (gg << 8) | bb
  color = cache[key] ||= GD::Color.rgb(rr, gg, bb)

  (0...dot).each do |dy|
    (0...dot).each do |dx|
      xx = x + dx
      yy = y + dy
      next if xx < 0 || yy < 0 || xx >= w || yy >= h
      set_px(img, xx, yy, color)
    end
  end
end

out = "donut_3d.png"
if img.respond_to?(:png)
  img.png(out)
elsif img.respond_to?(:save)
  img.save(out)
else
  raise "Export method not found"
end

puts "Wrote #{out}"


# frozen_string_literal: true

require "gd"

# ---------- helpers ----------
def clamp01(x)
  return 0.0 if x  1.0
  x
end

def hsv_to_rgb(h, s, v)
  h = h % 1.0
  s = clamp01(s)
  v = clamp01(v)

  i = (h * 6.0).floor
  f = h * 6.0 - i
  p = v * (1.0 - s)
  q = v * (1.0 - f * s)
  t = v * (1.0 - (1.0 - f) * s)

  r, g, b =
    case i % 6
    when 0 then [v, t, p]
    when 1 then [q, v, p]
    when 2 then [p, v, t]
    when 3 then [p, q, v]
    when 4 then [t, p, v]
    when 5 then [v, p, q]
    end

  [(r * 255).round, (g * 255).round, (b * 255).round]
end

# ---------- render params ----------
w = 900
h = 900
cx = w / 2.0
cy = h / 2.0

radius = 360.0
bg = 18

# Light direction (normalized-ish)
lx, ly, lz = -0.4, -0.35, 0.85
l_len = Math.sqrt(lx*lx + ly*ly + lz*lz)
lx /= l_len; ly /= l_len; lz /= l_len

# ---------- image ----------
img = GD::Image.new(w, h)

# background
bg_color = GD::Color.rgb(bg, bg, bg)
# If your binding has "filled_rectangle":
if img.respond_to?(:filled_rectangle)
  img.filled_rectangle(0, 0, w - 1, h - 1, bg_color)
end

# cache colors (libgd has a limited palette in some modes; truecolor is best)
color_cache = {}

# ---------- draw sphere ----------
# We'll iterate over a bounding square and fill pixels inside the circle.
min_x = (cx - radius).floor
max_x = (cx + radius).ceil
min_y = (cy - radius).floor
max_y = (cy + radius).ceil

( min_y..max_y ).each do |py|
  dy = (py - cy)
  ( min_x..max_x ).each do |px|
    dx = (px - cx)

    rr = dx*dx + dy*dy
    next if rr > radius*radius

    # Sphere surface normal from screen-space x,y
    # x,y in [-1,1]
    nx = dx / radius
    ny = dy / radius
    # z is positive hemisphere
    v = 1.0 - nx*nx - ny*ny
    nz = Math.sqrt([v, 0.0].max)

    # Use spherical coordinates for color:
    # longitude (atan2) -> hue
    # latitude (asin) -> saturation/value modulation
    lon = Math.atan2(ny, nx) # [-pi, pi]
    lat = Math.asin(nz)      # [0, pi/2] (front hemisphere)

    hue = (lon + Math::PI) / (2.0 * Math::PI) # 0..1
    sat = 0.95
    val = 0.95

    # Lighting: lambertian shading (dot of normal and light)
    ndotl = nx*lx + ny*ly + nz*lz
    shade = clamp01(0.15 + 0.85 * ndotl) # ambient + diffuse

    # Add subtle rim darkening for depth
    rim = clamp01(nz) # 0 at edge, 1 at center
    shade *= (0.65 + 0.35 * rim)

    r, g, b = hsv_to_rgb(hue, sat, val)

    # Apply shading
    r = (r * shade).round
    g = (g * shade).round
    b = (b * shade).round

    key = (r << 16) | (g << 8) | b
    color = color_cache[key] ||= GD::Color.rgb(r, g, b)

    # set pixel
    if img.respond_to?(:set_pixel)
      img.set_pixel(px, py, color)
    elsif img.respond_to?(:pixel)
      img.pixel(px, py, color)
    else
      # fallback name you might have
      img[pX, py] = color
    end
  end
end

# Optional highlight specular dot (adds pop)
if img.respond_to?(:filled_ellipse)
  highlight = GD::Color.rgb(255, 255, 255)
  img.filled_ellipse((cx - radius*0.25).round, (cy - radius*0.25).round, (radius*0.25).round, (radius*0.18).round, highlight)
end

# ---------- export ----------
out = "sphere_multicolor.png"
if img.respond_to?(:png)
  img.png(out)
elsif img.respond_to?(:save)
  img.save(out)
else
  raise "Don't know how to export PNG with this GD binding"
end

puts "Wrote #{out}"


Leave a comment