Skip to content

Instantly share code, notes, and snippets.

@ingoogni
Last active April 2, 2025 14:38
Show Gist options
  • Save ingoogni/8e1f74f8ea7250da778a202648e96e9b to your computer and use it in GitHub Desktop.
Save ingoogni/8e1f74f8ea7250da778a202648e96e9b to your computer and use it in GitHub Desktop.
scripting in Nim with nimscripter
compile
> nim c -d:release -d:danger renderer.nim
to render image from prompt:
> renderer sincos.nims
Render a function to a grey scale image, using nimscripter https://github.com/beef331/nimscripter for realtime scriptable applications.
Tonemapping can be done with GreyMap in the Pattern object. It uses linear interpolation to map input values to output values per section.
`GreyMap* = seq[tuple[greyVal: float, greyTarget: float]]`
Turbulence controls the distorion of the pattern.
`Turbulence* = tuple[strength: Vec2, lambda: float, omega: float, octaves: int]`
import math, random
type
Vec2* = tuple[x, y: float]
# Permutation table for Perlin noise
var perm: array[512, int]
# Initialize the permutation table
proc initPermutation*(seed: int) =
var p: array[256, int]
for i in 0..<256:
p[i] = i
randomize(seed)
for i in countdown(255, 1):
let j = rand(i)
swap(p[i], p[j])
for i in 0..<256:
perm[i] = p[i]
perm[i+256] = p[i]
# Gradient vectors for 2D Perlin noise
let grad2 = [
(x: 1.0, y: 1.0), (x: -1.0, y: 1.0),
(x: 1.0, y: -1.0), (x: -1.0, y: -1.0),
(x: 1.0, y: 0.0), (x: -1.0, y: 0.0),
(x: 0.0, y: 1.0), (x: 0.0, y: -1.0)
]
proc fade(t: float): float =
# Perlin's improved smooth step function: 6t^5 - 15t^4 + 10t^3
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
proc fadeDeriv(t: float): float =
# Derivative of Perlin's fade function: 30t^4 - 60t^3 + 30t^2
return 30.0 * t * t * (t * (t - 2.0) + 1.0)
proc dot(g: Vec2, x, y: float): float =
return g.x * x + g.y * y
proc gradIndex(hash: int): int =
return hash and 7 # Use lower 3 bits, modulo 8
proc lerp(a, b, t: float): float =
return a + t * (b - a)
# 2D Perlin Noise function
proc noise*(x, y: float): float =
let
# Find unit square that contains point
X = int(floor(x)) and 255
Y = int(floor(y)) and 255
# Find relative x, y of point in square
xf = x - floor(x)
yf = y - floor(y)
# Fade curves
u = fade(xf)
v = fade(yf)
# Hash coords of the square's corners
A = perm[X] + Y
B = perm[X + 1] + Y
# Get gradients
g00 = grad2[gradIndex(perm[A])]
g01 = grad2[gradIndex(perm[A + 1])]
g10 = grad2[gradIndex(perm[B])]
g11 = grad2[gradIndex(perm[B + 1])]
# Dot products
n00 = dot(g00, xf, yf)
n01 = dot(g01, xf, yf - 1.0)
n10 = dot(g10, xf - 1.0, yf)
n11 = dot(g11, xf - 1.0, yf - 1.0)
# Bilinear interpolation
nx0 = lerp(n00, n10, u)
nx1 = lerp(n01, n11, u)
nxy = lerp(nx0, nx1, v)
return nxy
# dNoise2D - returns the gradient of the noise function in 2D
proc dNoise2D*(x, y: float): Vec2 =
# Find unit square that contains point
let
X = int(floor(x)) and 255
Y = int(floor(y)) and 255
# Find relative x, y of point in square
xf = x - floor(x)
yf = y - floor(y)
# Fade curves and their derivatives
u = fade(xf)
v = fade(yf)
du = fadeDeriv(xf)
dv = fadeDeriv(yf)
# Hash coordinates of the square's corners
A = perm[X] + Y
B = perm[X + 1] + Y
# Gradients for the corners
g00 = grad2[gradIndex(perm[A])]
g01 = grad2[gradIndex(perm[A+1])]
g10 = grad2[gradIndex(perm[B])]
g11 = grad2[gradIndex(perm[B+1])]
# Calculate the noise contribution from each corner
n00 = dot(g00, xf, yf)
n01 = dot(g01, xf, yf - 1.0)
n10 = dot(g10, xf - 1.0, yf)
n11 = dot(g11, xf - 1.0, yf - 1.0)
# For the partial derivative with respect to x:
# - Direct contribution from gradient at each corner
# - Contribution due to change in interpolation weight
gx00 = g00.x
gx01 = g01.x
gx10 = g10.x
gx11 = g11.x
nx0dx = lerp(gx00, gx10, u) + (n10 - n00) * du
nx1dx = lerp(gx01, gx11, u) + (n11 - n01) * du
nxydx = lerp(nx0dx, nx1dx, v)
gy00 = g00.y
gy01 = g01.y
gy10 = g10.y
gy11 = g11.y
ny0dy = lerp(gy00, gy10, u)
ny1dy = lerp(gy01, gy11, u)
nx0 = lerp(n00, n10, u)
nx1 = lerp(n01, n11, u)
nxydy = lerp(ny0dy, ny1dy, v) + (nx1 - nx0) * dv
return (x: nxydx, y: nxydy)
import math
type
Gamma* = enum
linear, srgb, bt709
GammaFunction = proc(val: float): float
PGM* = object
path*: string
normalize*: bool = true
bit16*: bool = false
brightness*: float = 0.0
contrast*: float = 0.0
midpoint*: float = 0.5
gamma*: Gamma = linear
proc initPGM*(path: string): PGM =
result = PGM(
path: path,
normalize: true,
bit16: false,
brightness: 0.0,
contrast: 0.0,
midpoint: 0.5,
gamma: linear
)
proc brightnessContrast*(
value: float,
brightness: float,
contrast: float,
midpoint: float = 0.5
): float =
## Brightness and contrast with configurable midpoint
## Parameters:
## value: Input value in range [0.0, 1.0]
## brightness: Brightness adjustment [-1.0, 1.0]
## contrast: Contrast adjustment [-1.0, 1.0]
## midpoint: The pivot point for contrast adjustment [0.0, 1.0]
## Returns: Adjusted value clamped to [0.0, 1.0]
var pixelValue = value + brightness
let contrastFactor = tan((contrast + 1) * PI/4)
pixelValue = midpoint + contrastFactor * (pixelValue - midpoint)
return max(0.0, min(1.0, pixelValue))
proc linearGamma(val: float): float = #silly! Compiler fodder?
return val
proc linearToBT709*(val: float): float =
## Converts a linear value to BT.709 gamma space
## Input and output are in range [0.0, 1.0]
if val >= 0.018:
return 1.099 * pow(val, 0.45) - 0.099
else:
return 4.5 * val
proc bt709ToLinear*(val: float): float =
## Converts a BT.709 gamma value back to linear space
## Input and output are in range [0.0, 1.0]
if val >= 0.081:
return pow((val + 0.099) / 1.099, 1.0/0.45)
else:
return val / 4.5
proc linearToSRGB*(val: float): float =
## Converts a linear grayscale value to sRGB space
if val > 0.0031308:
return 1.055 * pow(val, 1.0/2.4) - 0.055
else:
return 12.92 * val
proc sRGBToLinear*(val: float): float =
## Converts a grayscale SRGB gamma value back to linear space
if val > 0.04045:
return pow((val + 0.055) / 1.055, 2.4)
else:
return val / 12.92
# PGM output function
proc writePGM*(
pgm: PGM,
data: seq[seq[float]]
) =
## Writes a 2D array of float values to a binary PGM file (P5 format)
## Parameters:
## filename: Output file path
## data: 2D sequence of float values
## normalize: If true, values will be scaled to 0-1 range
## bit16: If true, outputs 16-bit PGM, otherwise 8-bit
## gamma: linear, srgb, bt709
let height = data.len
if height == 0:
echo "Error: Empty data"
return
let width = data[0].len
# Find min/max for normalization if needed
var minVal = float.high
var maxVal = float.low
if pgm.normalize:
for y in 0..<height:
for x in 0..<width:
minVal = min(minVal, data[y][x])
maxVal = max(maxVal, data[y][x])
else:
minVal = 0.0
maxVal = 1.0
let range = maxVal - minVal
let maxPixelValue = if pgm.bit16: 65535 else: 255
var gammaTxt: string = ""
var applyGamma: GammaFunction
if pgm.gamma == srgb:
applyGamma = linearToSRGB
gammaTxT = "srgb"
elif pgm.gamma == bt709:
applyGamma = linearToBT709
gammaTxt = "BT.709"
else:
applyGamma = linearGamma
gammaTxt = "linear"
var f = open(pgm.path, fmWrite)
defer: f.close()
f.write("P5\n")
f.write("#Gamma = " & gammaTxt & "\n")
f.write($width & " " & $height & "\n")
f.write($maxPixelValue & "\n")
if pgm.bit16:
var pixelData = newSeq[uint16](width * height)
var i = 0
for y in 0..<height:
for x in 0..<width:
var normalizedVal =
if range > 0.0: (data[y][x] - minVal) / range
else: 0.0
normalizedVal = normalizedVal.brightnessContrast(pgm.brightness,
pgm.contrast, pgm.midpoint)
normalizedVal = normalizedVal.applyGamma
let pixelVal = uint16(normalizedVal * float(maxPixelValue))
pixelData[i] = pixelVal
i += 1
# Write binary data as big-endian bytes
var byteData = newSeq[uint8](pixelData.len * 2)
for i in 0..<pixelData.len:
byteData[i * 2] = uint8(pixelData[i] shr 8) # High byte
byteData[i * 2 + 1] = uint8(pixelData[i] and 0xFF) # Low byte
discard f.writeBytes(byteData, 0, byteData.len)
else:
# 8-bit version
var pixelData = newSeq[uint8](width * height)
var i = 0
for y in 0..<height:
for x in 0..<width:
var normalizedVal =
if range > 0.0: (data[y][x] - minVal) / range
else: 0.0
normalizedVal = normalizedVal.brightnessContrast(pgm.brightness,
pgm.contrast, pgm.midpoint)
normalizedVal = normalizedVal.applyGamma()
pixelData[i] = uint8(normalizedVal * float(maxPixelValue))
i += 1
discard f.writeBytes(pixelData, 0, pixelData.len)
import std/[os, parseopt]
import pgm, dnoise2d
import nimscripter
type
Vec2* = tuple[x, y: float] #should use vmath!
GreyMap* = seq[tuple[greyVal: float, greyTarget: float]]
Turbulence* = tuple[strength: Vec2, lambda: float, omega: float, octaves: int]
Pattern* = object
funk*: proc(x, y: float): float
greymap*: GreyMap
turbulence*: Turbulence
initPermutation(7) #for DNoise
proc lmap*(val, minin, maxin, minout, maxout: float): float {.inline.} =
((val - minin) / (maxin - minin)) * (maxout - minout) + minout
proc greyMap*(gmap: GreyMap, value: float): float =
result = 0.0
if value < gmap[0].greyVal:
result = gmap[0].greyTarget
elif value > gmap[gmap.len - 1].greyVal:
result = gmap[gmap.len - 1].greyTarget
else:
for i in 0..<gmap.len - 1:
if value >= gmap[i].greyVal and value <= gmap[i+1].greyVal:
result = lmap(
value,
gmap[i].greyVal,
gmap[i+1].greyVal,
gmap[i].greyTarget,
gmap[i+1].greyTarget
)
break
proc turbulence*(p: Vec2, turb: Turbulence): Vec2 =
result = p
var
freq = 0.5
amp = 0.5
for i in 0..<turb.octaves:
# Point-dependent noise vector
let
p2 = (x: result.x * freq, y: result.y * freq)
noise = dNoise2D(p2.x, p2.y)
result.x += noise.x * amp * turb.strength.x
result.y += noise.y * amp * turb.strength.y
# Scale for next octave
freq *= turb.lambda
amp *= turb.omega / 2
template render*(
patt: Pattern,
width, height: int,
viewportWidth, viewportHeight: float,
viewportX: float = 0.0,
viewportY: float = 0.0
): seq[seq[float]] =
var minVal = float.high
var maxVal = float.low
var pixel = newSeq[seq[float]](height)
let
aspectRatio = width.float / height.float
actualViewportHeight = if viewportHeight <= 0.0:
viewportWidth / aspectRatio
else:
viewportHeight
for y in 0..<height:
pixel[y] = newSeq[float](width)
let yf = viewportY + (y.float / height.float) * actualViewportHeight
for x in 0..<width:
let
xf = viewportX + (x.float / width.float) * viewportWidth
point: Vec2 = (x: xf, y: yf)
warped = if patt.turbulence.strength.x *
patt.turbulence.strength.y != 0.0:
turbulence(point, patt.turbulence)
else:
point
let val = patt.funk(warped.y, warped.x)
pixel[y][x] = val
minVal = min(minVal, val)
maxVal = max(maxVal, val)
echo "minVal: ", minVal,"\n","maxVal: ", maxVal
let valRange = maxVal - minVal
var normalizedVal: float
if patt.greymap.len > 0:
for y in 0..<height:
for x in 0..<width:
normalizedVal =
if valRange > 0.0: (pixel[y][x] - minVal) / valRange
else: 0.0
pixel[y][x] = greymap(patt.greymap, normalizedVal)
pixel
exportTo(renderImpl,
Vec2, GreyMap, Turbulence, Pattern, Gamma, PGM,
initPGM, writePGM, turbulence, greyMap, lmap, render
)
proc main() =
let cp = commandLineParams()
if cp == @[]:
echo "no arguments given"
quit(3)
let co = quoteShellCommand(cp)
var positionalArgs = newSeq[string]()
var optparser = initOptParser(co)
for kind, key, val in optparser.getopt():
case kind
of cmdArgument:
positionalArgs.add(key)
else:
continue
if positionalArgs[0] == "":
echo "no file given"
quit(3)
elif not fileExists(positionalArgs[0]):
echo "file does not exist"
quit(3)
let intr = loadScript(
NimScriptPath(positionalArgs[0]),
implNimScriptModule(renderImpl)
)
main()
import math
proc shuheiKawachi(a: float, b: float): proc(x: float, y: float): float =
proc shuheiKawachi(x: float, y: float): float =
(cos(x) * cos(y) + cos((sqrt(a) * x - y) / b) *
cos((x + sqrt(a) * y) / b) + cos((sqrt(a) * x + y) / b) *
cos((x - sqrt(a) * y) / b)) / 6 + 0.5
return shuheiKawachi
proc absShuheiKawachi(a: float, b: float): proc(x: float, y: float): float =
proc absShuheiKawachi(x: float, y: float): float =
abs(cos(x) * cos(y) + cos((sqrt(a) * x - y) / b)*
cos((x + sqrt(a) * y) / b) + cos((sqrt(a) * x + y) / b) *
cos((x - sqrt(a) * y) / b)) / 3
return absShuheiKawachi
proc main() =
var img = initPGM("shuheiKawachi.pgm")
img.gamma = srgb
let
#sK = shuheiKawachi(TAU, PI)
sK = absShuheiKawachi(TAU, PI)
gm = Pattern(
funk: sK,
greymap: @[(0.0,0.0),(1.0,1.0)],
turbulence: (
strength: (x: 4.6, y: 0.2),
lambda: 0.5,
omega: 0.3,
octaves: 8
)
)
width = 640
height = 480
viewportWidth = 40.0
viewportHeight = 0.0 #0.0 means auto calculate based on aspect ratio, width, height
#viewportHeight = viewportWidth * (height / width) # Maintain aspect ratio
grid = gm.render(width, height, viewportWidth, viewportHeight)
img.writePGM(grid)
when isMainModule:
main()
import math
proc sincos(x, y: float): float =
return (1 + (sin(x) * cos(y))) * 0.5
proc main() =
var img = initPGM("sincos.pgm")
img.gamma = srgb
let
gm = Pattern(
funk: sincos,
greymap: @[(0.0,0.0),(0.5,0.2),(0.8,0.7),(1.0,0.0)],
turbulence: (
strength: (x: 1.6, y: 2.2),
lambda: 2.5,
omega: 0.7,
octaves: 6
)
)
width = 640
height = 480
viewportWidth = 30.0
viewportHeight = 0.0 #0.0 means auto calculate based on aspect ratio, width, height
#viewportHeight = viewportWidth * (height / width) # Maintain aspect ratio
grid = gm.render(width, height, viewportWidth, viewportHeight)
img.writePGM(grid)
when isMainModule:
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment