singe/util/neonCoastCar.singe
2026-10-06 16:54:44 -05:00

261 lines
8.5 KiB
Text

-- Renders the Neon Coast car with Singe's own 3D renderer, for util/neonCoastCar.sh to turn into a
-- sprite sheet. The car is built here from lofted cross sections, wheels, and trim: a low wedge
-- of a sports car, our own design. Each steering angle is drawn twice, over black and over white,
-- and the pair gives every pixel's true colour and alpha, glass and soft edges included.
--
-- Run headless with --deterministic; it saves car<angle><b|w>.png and quits.
dofile("Singe/Framework.singe")
local W = discGetWidth() -- The canvas the shell asks for (util/neonCoastCar.sh)
local H = discGetHeight()
local ANGLES = { -30, -20, -10, 0, 10, 20, 30 }
local RED = { 215, 14, 30 }
-- The traffic: the same car straight from behind in other paints (CAR_TRAFFIC set by the wrapper).
local PAINTS = { { 20, 110, 230 }, { 245, 200, 40 }, { 230, 230, 240 }, { 40, 190, 90 } }
-- Each picture to take: the car's angle and its paint.
local SHOTS = {}
if CAR_TRAFFIC then
for _, paint in ipairs(PAINTS) do
SHOTS[#SHOTS + 1] = { angle = 0, paint = paint }
end
else
for _, angle in ipairs(ANGLES) do
SHOTS[#SHOTS + 1] = { angle = angle, paint = RED }
end
end
local SETTLE = 3 -- Frames each picture is held before it is taken
local frames = 0
local function material(r, g, b, metallic, roughness)
local m = materialNew()
materialSetColor(m, r, g, b)
materialSetMetallic(m, metallic)
materialSetRoughness(m, roughness)
return m
end
-- A loft: rings of points (each a list of {x, y} at a z) joined into a closed skin, capped at both
-- ends. Normals are left to meshNew, so the skin shades smoothly.
local function loft(stations)
local positions = {}
local indices = {}
local ring = #stations[1].points
for _, s in ipairs(stations) do
for _, p in ipairs(s.points) do
positions[#positions + 1] = p[1]
positions[#positions + 1] = p[2]
positions[#positions + 1] = s.z
end
end
for i = 0, #stations - 2 do
for j = 0, ring - 1 do
local a = i * ring + j + 1
local b = i * ring + (j + 1) % ring + 1
local c = (i + 1) * ring + (j + 1) % ring + 1
local d = (i + 1) * ring + j + 1
indices[#indices + 1] = a
indices[#indices + 1] = c
indices[#indices + 1] = d
indices[#indices + 1] = a
indices[#indices + 1] = b
indices[#indices + 1] = c
end
end
-- Caps: a fan from each end ring's middle.
for _, e in ipairs({ { 0, false }, { #stations - 1, true } }) do
local s = stations[e[1] + 1]
local cx = 0
local cy = 0
for _, p in ipairs(s.points) do
cx = cx + p[1] / ring
cy = cy + p[2] / ring
end
positions[#positions + 1] = cx
positions[#positions + 1] = cy
positions[#positions + 1] = s.z
local centre = #positions / 3
for j = 0, ring - 1 do
local a = e[1] * ring + j + 1
local b = e[1] * ring + (j + 1) % ring + 1
indices[#indices + 1] = centre
if e[2] then
indices[#indices + 1] = a
indices[#indices + 1] = b
else
indices[#indices + 1] = b
indices[#indices + 1] = a
end
end
end
return meshNew(positions, nil, nil, indices)
end
-- The body's cross section at half width w, beltline belt, sill sill: a rounded shoulder, slab
-- sides tucking in at the bottom, a flat floor.
local function bodySection(z, w, belt, sill)
return {
z = z,
points = {
{ 0, belt + 0.05 }, { -w * 0.55, belt + 0.03 }, { -w * 0.88, belt - 0.01 }, { -w, belt - 0.12 },
{ -w, sill + 0.14 }, { -w * 0.93, sill }, { w * 0.93, sill }, { w, sill + 0.14 },
{ w, belt - 0.12 }, { w * 0.88, belt - 0.01 }, { w * 0.55, belt + 0.03 },
},
}
end
local function cabinSection(z, w, base, top)
return {
z = z,
points = { { 0, top }, { -w * 0.7, top - 0.03 }, { -w, base + (top - base) * 0.25 }, { -w * 1.02, base }, { w * 1.02, base }, { w, base + (top - base) * 0.25 }, { w * 0.7, top - 0.03 } },
}
end
local function part(mesh, mat, x, y, z, rx, ry, rz)
local n = nodeNew(CAR)
nodeSetMesh(n, mesh, mat)
nodeSetPosition(n, x, y, z)
nodeSetRotation(n, rx or 0, ry or 0, rz or 0)
return n
end
local function buildCar()
local paint = material(RED[1], RED[2], RED[3], 0.55, 0.14)
PAINT = paint
local glass = material(14, 18, 30, 0.9, 0.06)
local black = material(12, 12, 14, 0, 0.6)
local trim = material(30, 30, 34, 0.5, 0.35)
local chrome = material(220, 220, 230, 1, 0.12)
local tyre = material(18, 18, 20, 0, 0.85)
local lamp = material(60, 0, 0, 0, 0.3)
local bright = material(90, 10, 10, 0, 0.3)
local plate = material(230, 230, 220, 0, 0.5)
materialSetEmissive(lamp, 255, 20, 40)
materialSetEmissive(bright, 255, 170, 170)
CAR = nodeNew()
-- The body: a wedge, low and narrow at the nose, widest over the rear wheels, a flat tail.
local sill = 0.2
local body = {
bodySection(-2.25, 0.70, 0.50, sill + 0.08), bodySection(-2.05, 0.86, 0.55, sill), bodySection(-1.6, 0.94, 0.60, sill),
bodySection(-0.9, 0.97, 0.66, sill), bodySection(-0.1, 0.98, 0.70, sill), bodySection(0.7, 1.0, 0.73, sill),
bodySection(1.4, 1.02, 0.76, sill), bodySection(1.95, 1.0, 0.78, sill), bodySection(2.2, 0.97, 0.77, sill + 0.03),
}
part(loft(body), paint, 0, 0, 0)
-- The cabin: all glass, a raked screen and a sloping rear window, with a painted roof on top.
local cabin = {
cabinSection(-0.95, 0.80, 0.64, 0.66), cabinSection(-0.35, 0.74, 0.68, 1.10), cabinSection(0.45, 0.70, 0.71, 1.14),
cabinSection(1.05, 0.74, 0.74, 1.02), cabinSection(1.55, 0.84, 0.76, 0.79),
}
part(loft(cabin), glass, 0, 0, 0)
part(meshBox(1.24, 0.035, 0.85), paint, 0, 1.135, 0.08)
-- The tail: a black panel, a full-width lamp bar with brighter cores, a plate, and twin pipes.
part(meshBox(1.86, 0.30, 0.04), black, 0, 0.53, 2.205)
part(meshBox(1.78, 0.11, 0.05), lamp, 0, 0.62, 2.22)
part(meshBox(0.36, 0.06, 0.05), bright, -0.62, 0.62, 2.235)
part(meshBox(0.36, 0.06, 0.05), bright, 0.62, 0.62, 2.235)
for i = -3, 3 do
part(meshBox(0.02, 0.09, 0.03), black, i * 0.12, 0.62, 2.25)
end
part(meshBox(0.52, 0.12, 0.02), plate, 0, 0.43, 2.23)
part(meshCylinder(0.065, 0.3, 16), chrome, -0.48, 0.27, 2.2, 90, 0, 0)
part(meshCylinder(0.065, 0.3, 16), chrome, 0.48, 0.27, 2.2, 90, 0, 0)
-- A low wing across the tail on two posts.
part(meshBox(1.8, 0.05, 0.3), paint, 0, 0.97, 2.0)
part(meshBox(0.05, 0.18, 0.1), trim, -0.6, 0.87, 2.0)
part(meshBox(0.05, 0.18, 0.1), trim, 0.6, 0.87, 2.0)
-- Intake strakes along each rear flank, and black skirts under the doors.
for side = -1, 1, 2 do
for i = 0, 4 do
part(meshBox(0.04, 0.035, 1.25), black, side * 1.005, 0.40 + i * 0.065, 0.45)
end
part(meshBox(0.05, 0.12, 2.3), black, side * 0.95, 0.27, 0.05)
end
-- Mirrors.
part(meshBox(0.16, 0.08, 0.12), paint, -0.98, 0.82, -0.55)
part(meshBox(0.16, 0.08, 0.12), paint, 0.98, 0.82, -0.55)
-- Wheels: wide tyres with chrome rims, the rears wider.
for _, w in ipairs({ { -0.9, -1.35, 0.25 }, { 0.9, -1.35, 0.25 }, { -0.92, 1.38, 0.32 }, { 0.92, 1.38, 0.32 } }) do
part(meshCylinder(0.34, w[3], 28), tyre, w[1], 0.34, w[2], 0, 0, 90)
part(meshCylinder(0.22, w[3] + 0.01, 20), chrome, w[1] + (w[1] > 0 and 0.002 or -0.002), 0.34, w[2], 0, 0, 90)
end
-- Under the car, a dark floor so the light does not show through.
part(meshBox(1.8, 0.04, 4.2), black, 0, 0.2, 0)
end
sceneEnable(true)
sceneSetAmbient(70, 60, 80)
sceneSetAntialias(true)
-- No tone curve, so the white background comes out white and the matte is exact.
sceneSetTonemap(TONEMAP_NONE)
buildCar()
local key = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(key, 3, 8, -4)
nodeLookAt(key, 0, 0, 0)
lightSetIntensity(key, 2.6)
lightSetColor(key, 255, 230, 240)
local fill = lightNew(LIGHT_POINT)
nodeSetPosition(fill, -3, 3, 8)
lightSetIntensity(fill, 40)
lightSetColor(fill, 120, 160, 255)
local rim = lightNew(LIGHT_POINT)
nodeSetPosition(rim, 4, 2.5, 6)
lightSetIntensity(rim, 30)
lightSetColor(rim, 255, 80, 200)
local top = lightNew(LIGHT_POINT)
nodeSetPosition(top, 0, 6, 3)
lightSetIntensity(top, 25)
local camera = nodeNew()
cameraSet(camera)
cameraSetPerspective(26, 0.1, 100)
nodeSetPosition(camera, 0, 3.1, 11)
nodeLookAt(camera, 0, 0.55, 0)
overlaySetResolution(W, H)
function onOverlayUpdate()
local shot = frames // SETTLE
local take = SHOTS[shot // 2 + 1]
local white = shot % 2 == 1
frames = frames + 1
overlayClear()
if not take then
singeQuit()
return OVERLAY_UPDATED
end
nodeSetRotation(CAR, 0, take.angle, 0)
materialSetColor(PAINT, take.paint[1], take.paint[2], take.paint[3])
sceneSetBackground(white and 255 or 0, white and 255 or 0, white and 255 or 0, 255)
if frames % SETTLE == 0 then
singeScreenshot(string.format("shot%02d%s", shot // 2 + 1, white and "w" or "b"))
end
return OVERLAY_UPDATED
end