Files
zzg 4dddb62958
ci / test (push) Canceled after 0s
initial commit
2026-09-25 21:31:54 -07:00

162 lines
4.4 KiB
Go

// Package render turns a scene into an image by path tracing.
package render
import (
"context"
"image"
"image/color"
"math"
"math/rand"
"runtime"
"sync"
"sync/atomic"
"github.com/zg/tracer/scene"
"github.com/zg/tracer/vec"
)
// Options configures a render.
type Options struct {
Width, Height int
Samples int // rays per pixel; noise falls as 1/sqrt(samples)
MaxDepth int // bounce limit, to bound work on a shiny scene
Background vec.Vec3 // used when a ray escapes; zero means the sky gradient
UseSky bool
Workers int // 0 uses GOMAXPROCS
Seed int64 // 0 uses a fixed seed, so renders are reproducible
}
// withDefaults fills in unset fields.
func (o Options) withDefaults() Options {
if o.Width <= 0 {
o.Width = 400
}
if o.Height <= 0 {
o.Height = 225
}
if o.Samples <= 0 {
o.Samples = 32
}
if o.MaxDepth <= 0 {
o.MaxDepth = 16
}
if o.Workers <= 0 {
o.Workers = runtime.GOMAXPROCS(0)
}
return o
}
// Render traces the scene and returns the image.
//
// Rows are distributed across workers. Each worker owns its own *rand.Rand
// seeded from the row index: sharing one generator would serialise the
// workers on its mutex, and seeding from the clock would make renders
// unreproducible.
func Render(ctx context.Context, world scene.Surface, cam *scene.Camera, opts Options) (*image.RGBA, error) {
opts = opts.withDefaults()
img := image.NewRGBA(image.Rect(0, 0, opts.Width, opts.Height))
var nextRow atomic.Int64
var wg sync.WaitGroup
for range opts.Workers {
wg.Add(1)
go func() {
defer wg.Done()
for {
y := int(nextRow.Add(1) - 1)
if y >= opts.Height {
return
}
select {
case <-ctx.Done():
return
default:
}
// Seeding per row keeps the image identical no matter how
// rows happen to be distributed across workers.
rng := rand.New(rand.NewSource(opts.Seed + int64(y)*9781))
renderRow(img, world, cam, opts, y, rng)
}
}()
}
wg.Wait()
return img, ctx.Err()
}
func renderRow(img *image.RGBA, world scene.Surface, cam *scene.Camera, opts Options, y int, rng *rand.Rand) {
for x := range opts.Width {
var sum vec.Vec3
for range opts.Samples {
// Jitter within the pixel: this is the anti-aliasing.
u := (float64(x) + rng.Float64()) / float64(opts.Width-1)
v := (float64(y) + rng.Float64()) / float64(opts.Height-1)
r := cam.Ray(u, v, rng)
sum = sum.Add(rayColour(r, world, opts, opts.MaxDepth, rng))
}
c := sum.Scale(1 / float64(opts.Samples))
// Image rows run top-down but the camera's v axis runs bottom-up.
img.Set(x, opts.Height-1-y, toRGBA(c))
}
}
// rayColour follows a ray through the scene, accumulating attenuation until
// it escapes, is absorbed, or runs out of bounces.
func rayColour(r scene.Ray, world scene.Surface, opts Options, depth int, rng *rand.Rand) vec.Vec3 {
// Iterative rather than recursive: depth can be large, and each bounce
// multiplies attenuation, so a loop carrying the product is equivalent
// and cannot overflow the stack.
throughput := vec.New(1, 1, 1)
acc := vec.Vec3{}
for range depth {
// tMin is a small epsilon, not zero: a scattered ray starts exactly
// on the surface it just hit, and floating point error would
// otherwise let it immediately re-hit the same point. That is the
// classic "shadow acne" artefact.
h, ok := world.Hit(r, 1e-3, math.Inf(1))
if !ok {
return acc.Add(throughput.Mul(background(r, opts)))
}
acc = acc.Add(throughput.Mul(h.Material.Emitted()))
out, attenuation, scattered := h.Material.Scatter(r, h, rng)
if !scattered {
return acc
}
throughput = throughput.Mul(attenuation)
r = out
}
// Bounce limit reached: no more light can be gathered along this path.
return acc
}
func background(r scene.Ray, opts Options) vec.Vec3 {
if !opts.UseSky {
return opts.Background
}
t := 0.5 * (r.Dir.Unit().Y + 1)
return vec.Lerp(vec.New(1, 1, 1), vec.New(0.5, 0.7, 1.0), t)
}
// toRGBA converts linear colour to 8-bit sRGB-ish output.
func toRGBA(c vec.Vec3) color.RGBA {
// Gamma 2 correction. Without it, renders look markedly too dark,
// because the accumulated values are linear but displays are not.
return color.RGBA{
R: channel(c.X),
G: channel(c.Y),
B: channel(c.Z),
A: 255,
}
}
func channel(v float64) uint8 {
if math.IsNaN(v) {
return 0
}
v = math.Sqrt(math.Max(0, v)) // gamma 2
return uint8(256 * math.Min(math.Max(v, 0), 0.999))
}