Skip to content

Commit d86a581

Browse files
committed
Added simplistic raytracer lib and 3d print preview program.
1 parent 0098f9a commit d86a581

File tree

3 files changed

+383
-0
lines changed

3 files changed

+383
-0
lines changed

print3d-view.lua

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
local component = require("component")
2+
local event = require("event")
3+
local keyboard = require("keyboard")
4+
local shell = require("shell")
5+
local term = require("term")
6+
local unicode = require("unicode")
7+
local raytracer = require("raytracer")
8+
9+
local args = shell.parse(...)
10+
if #args < 1 then
11+
io.write("Usage: print3d-view FILE [fov]\n")
12+
os.exit(0)
13+
end
14+
15+
-- model loading
16+
17+
local file, reason = io.open(args[1], "r")
18+
if not file then
19+
io.stderr:write("Failed opening file: " .. reason .. "\n")
20+
os.exit(1)
21+
end
22+
23+
local rawdata = file:read("*all")
24+
file:close()
25+
local data, reason = load("return " .. rawdata)
26+
if not data then
27+
io.stderr:write("Failed loading model: " .. reason .. "\n")
28+
os.exit(2)
29+
end
30+
data = data()
31+
32+
-- set up raytracer
33+
34+
local rt = raytracer.new()
35+
rt.camera.position={-22+8,20+8,-22+8}
36+
rt.camera.target={8,8,8}
37+
rt.camera.fov=tonumber(args[2]) or 90
38+
39+
local state
40+
local function setState(value)
41+
if state ~= value then
42+
state = value
43+
rt.model = {}
44+
for _, shape in ipairs(data.shapes or {}) do
45+
if not not shape.state == state then
46+
table.insert(rt.model, shape)
47+
end
48+
end
49+
if state and #rt.model < 1 then -- no shapes for active state
50+
setState(false)
51+
end
52+
end
53+
end
54+
setState(false)
55+
56+
-- set up gpu
57+
58+
local gpu = component.gpu
59+
local cfg, cbg
60+
local function setForeground(color)
61+
if cfg ~= color then
62+
gpu.setForeground(color)
63+
cfg = color
64+
end
65+
end
66+
local function setBackground(color)
67+
if cbg ~= color then
68+
gpu.setBackground(color)
69+
cbg = color
70+
end
71+
end
72+
73+
-- helper functions
74+
75+
local function vrotate(v, origin, angle)
76+
local x, y = v[1]-origin[1], v[3]-origin[3]
77+
local s = math.sin(angle)
78+
local c = math.cos(angle)
79+
80+
local rotx = x * c + y * s
81+
local roty = -x * s + y * c
82+
return {rotx+origin[1], v[2], roty+origin[3]}
83+
end
84+
85+
local function ambient(normal)
86+
if math.abs(normal[1]) > 0.5 then
87+
return 0.6
88+
elseif math.abs(normal[3]) > 0.5 then
89+
return 0.8
90+
elseif normal[2] > 0 then
91+
return 1.0
92+
else
93+
return 0.4
94+
end
95+
end
96+
97+
local function hash(str)
98+
local result = 7
99+
for i=1,#str do
100+
result = (result*31 + string.byte(str, i))%0xFFFFFFFF
101+
end
102+
return result
103+
end
104+
105+
local function multiply(color, brightness)
106+
local r,b,g=(color/2^16)%256,(color/2^8)%256,color%256
107+
r = r*brightness
108+
g = g*brightness
109+
b = b*brightness
110+
return r*2^16+g*2^8+b
111+
end
112+
113+
local palette = {0x0000FF, 0x00FF00, 0x00FFFF, 0xFF0000, 0xFF00FF, 0xFFFF00, 0xFFFFFF}
114+
115+
-- render model
116+
while true do
117+
setForeground(0x000000)
118+
setBackground(0x000000)
119+
local rx, ry = gpu.getResolution()
120+
gpu.fill(1, 1, rx, ry, unicode.char(0x2580))
121+
122+
rt:render(rx, ry*2, function(x, y, shape, normal)
123+
local sx, sy = x, math.ceil(y / 2)
124+
local ch, fg, bg = gpu.get(sx, sy)
125+
local brightness = ambient(normal)
126+
local color = multiply(data.palette and data.palette[shape.texture] or palette[hash(shape.texture or "") % #palette + 1], brightness)
127+
if color == 0x000000 then return end
128+
if y % 2 == 1 then
129+
setBackground(bg)
130+
setForeground(color)
131+
else
132+
setBackground(color)
133+
setForeground(fg)
134+
end
135+
gpu.set(sx, sy, ch)
136+
end)
137+
138+
gpu.setForeground(0xFFFFFF)
139+
gpu.setBackground(0x000000)
140+
141+
gpu.set(1, ry, "[q] Quit [left/right] Rotate [space] Toggle state")
142+
os.sleep(0.1) -- consume events that arrived in the meantime
143+
while true do
144+
local _,_,_,code=event.pull("key_down")
145+
if code == keyboard.keys.q then
146+
term.clear()
147+
os.exit(0)
148+
elseif code == keyboard.keys.space then
149+
setState(not state)
150+
break
151+
elseif code == keyboard.keys.left then
152+
local step = 10
153+
if keyboard.isShiftDown() then step = 90 end
154+
rt.camera.position = vrotate(rt.camera.position, rt.camera.target, -step/180*math.pi)
155+
break
156+
elseif code == keyboard.keys.right then
157+
local step = 10
158+
if keyboard.isShiftDown() then step = 90 end
159+
rt.camera.position = vrotate(rt.camera.position, rt.camera.target, step/180*math.pi)
160+
break
161+
end
162+
end
163+
end

programs.cfg

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@
5454
note = "This is a slightly adjusted port from some code originally found on StackOverflow.",
5555
repo = "tree/master/noise.lua"
5656
},
57+
["librt"] = {
58+
files = {
59+
["master/raytracer.lua"] = "/lib"
60+
},
61+
name = "Simple Raytracing Library",
62+
description = "This package provides a library for performing very basic raytracing.",
63+
authors = "Sangar",
64+
note = "Only supports axis aligned bounding boxes.",
65+
repo = "tree/master/raytracer.lua"
66+
},
5767
["lisp"] = {
5868
files = {
5969
["master/lisp.lua"] = "/bin"
@@ -87,6 +97,18 @@
8797
note = "See the `/usr/share/models` directory for the example models.",
8898
repo = "tree/master/models"
8999
},
100+
["print3d-view"] = {
101+
files = {
102+
["master/print3d-view.lua"] = "/bin"
103+
},
104+
dependencies = {
105+
["librt"] = "/"
106+
},
107+
name = "3D Print Viewer",
108+
description = "This program allows previewing 3D model files used for print3d.",
109+
authors = "Sangar",
110+
repo = "tree/master/print3d-view.lua"
111+
},
90112
["derp"] = {
91113
files = {
92114
["master/idontexist.lua"] = "/bin"

raytracer.lua

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
--[[
2+
Very basic raytracer, passing results (i.e. hit "pixels") to a callback.
3+
4+
Usage:
5+
local rt = require("raytracer").new()
6+
table.insert(rt.model, {0,0,0,16,16,16})
7+
--rt.camera.position = {-20,20,0}
8+
--rt.camera.target = {8,8,8}
9+
--rt.camera.fov = 100
10+
rt:render(width, height, function(hitX, hitY, box, normal)
11+
-- do stuff with the hit information, e.g. set pixel at hitX/hitY to boxes color
12+
end)
13+
14+
Shapes must at least have their min/max coordinates given as the first six
15+
integer indexed entries of the table, as {minX,minY,minZ,maxX,maxY,maxZ}.
16+
The returned normal is a sequence with the x/y/z components of the normal.
17+
18+
The camera can be configured as shown in the example above, i.e. it has a
19+
position, target and field of view (which is in degrees).
20+
21+
MIT Licensed, Copyright Sangar 2015
22+
]]
23+
local M = {}
24+
25+
-- vector math stuffs
26+
27+
local function vadd(v1, v2)
28+
return {v1[1]+v2[1], v1[2]+v2[2], v1[3]+v2[3]}
29+
end
30+
local function vsub(v1, v2)
31+
return {v1[1]-v2[1], v1[2]-v2[2], v1[3]-v2[3]}
32+
end
33+
local function vmul(v1, v2)
34+
return {v1[1]*v2[1], v1[2]*v2[2], v1[3]*v2[3]}
35+
end
36+
local function vcross(v1, v2)
37+
return {v1[2]*v2[3]-v1[3]*v2[2], v1[3]*v2[1]-v1[1]*v2[3], v1[1]*v2[2]-v1[2]*v2[1]}
38+
end
39+
local function vmuls(v, s)
40+
return vmul(v, {s, s, s})
41+
end
42+
local function vdot(v1, v2)
43+
return v1[1]*v2[1] + v1[2]*v2[2] + v1[3]*v2[3]
44+
end
45+
local function vnorm(v)
46+
return vdot(v, v)
47+
end
48+
local function vlen(v)
49+
return math.sqrt(vnorm(v))
50+
end
51+
local function vnormalize(v)
52+
return vmuls(v, 1/vlen(v))
53+
end
54+
55+
-- collision stuffs
56+
57+
-- http://tog.acm.org/resources/GraphicsGems/gems/RayBox.c
58+
-- adjusted version also returning the surface normal
59+
local function collideRayBox(box, origin, dir)
60+
local inside = true
61+
local quadrant = {0,0,0}
62+
local minB = {box[1],box[2],box[3]}
63+
local maxB = {box[4],box[5],box[6]}
64+
local maxT = {0,0,0}
65+
local candidatePlane = {0,0,0}
66+
local sign = 0
67+
68+
-- Find candidate planes; this loop can be avoided if
69+
-- rays cast all from the eye(assume perpsective view)
70+
for i=1,3 do
71+
if origin[i] < minB[i] then
72+
quadrant[i] = true
73+
candidatePlane[i] = minB[i]
74+
inside = false
75+
sign = -1
76+
elseif origin[i] > maxB[i] then
77+
quadrant[i] = true
78+
candidatePlane[i] = maxB[i]
79+
inside = false
80+
sign = 1
81+
else
82+
quadrant[i] = false
83+
end
84+
end
85+
86+
-- Ray origin inside bounding box
87+
if inside then
88+
return nil
89+
end
90+
91+
-- Calculate T distances to candidate planes
92+
for i=1,3 do
93+
if quadrant[i] and dir[i] ~= 0 then
94+
maxT[i] = (candidatePlane[i] - origin[i]) / dir[i]
95+
else
96+
maxT[i] = -1
97+
end
98+
end
99+
100+
-- Get largest of the maxT's for final choice of intersection
101+
local whichPlane = 1
102+
for i=2,3 do
103+
if maxT[whichPlane] < maxT[i] then
104+
whichPlane = i
105+
end
106+
end
107+
108+
-- Check final candidate actually inside box
109+
if maxT[whichPlane] < 0 then return nil end
110+
local coord,normal = {0,0,0},{0,0,0}
111+
for i=1,3 do
112+
if whichPlane ~= i then
113+
coord[i] = origin[i] + maxT[whichPlane] * dir[i]
114+
if coord[i] < minB[i] or coord[i] > maxB[i] then
115+
return nil
116+
end
117+
else
118+
coord[i] = candidatePlane[i]
119+
normal[i] = sign
120+
end
121+
end
122+
123+
return coord, normal -- ray hits box
124+
end
125+
126+
local function trace(model, origin, dir)
127+
local bestBox, bestNormal, bestDist = nil, nil, math.huge
128+
for _, box in ipairs(model) do
129+
local hit, normal = collideRayBox(box, origin, dir)
130+
if hit then
131+
local dist = vlen(vsub(hit, origin))
132+
if dist < bestDist then
133+
bestBox = box
134+
bestNormal = normal
135+
bestDist = dist
136+
end
137+
end
138+
end
139+
return bestBox, bestNormal
140+
end
141+
142+
-- public api
143+
144+
function M.new()
145+
return setmetatable({model={},camera={position={-1,1,-1},target={0,0,0},fov=90}}, {__index=M})
146+
end
147+
148+
function M:render(w, h, f)
149+
if #self.model < 1 then return end
150+
-- overall model bounds, for quick empty space skipping
151+
local bounds = {self.model[1][1],self.model[1][2],self.model[1][3],self.model[1][4],self.model[1][5],self.model[1][6]}
152+
for _, shape in ipairs(self.model) do
153+
bounds[1] = math.min(bounds[1], shape[1])
154+
bounds[2] = math.min(bounds[2], shape[2])
155+
bounds[3] = math.min(bounds[3], shape[3])
156+
bounds[4] = math.max(bounds[4], shape[4])
157+
bounds[5] = math.max(bounds[5], shape[5])
158+
bounds[6] = math.max(bounds[6], shape[6])
159+
end
160+
bounds = {bounds}
161+
-- setup framework for ray generation
162+
local origin = self.camera.position
163+
local forward = vnormalize(vsub(self.camera.target, origin))
164+
local plane = vadd(origin, forward)
165+
local side = vcross(forward, {0,1,0})
166+
local up = vcross(forward, side)
167+
local lside = math.tan(self.camera.fov/2/180*math.pi)
168+
-- generate ray for each pixel, left-to-right, top-to-bottom
169+
local blanks = 0
170+
for sy = 1, h do
171+
local ry = (sy/h - 0.5)*lside
172+
local py = vadd(plane, vmuls(up, ry))
173+
for sx = 1, w do
174+
local rx = (sx/w - 0.5)*lside
175+
local px = vadd(py, vmuls(side, rx))
176+
local dir = vnormalize(vsub(px, origin))
177+
if trace(bounds, origin, dir) then
178+
local box, normal = trace(self.model, origin, dir)
179+
if box then
180+
blanks = 0
181+
if f(sx, sy, box, normal) == false then
182+
return
183+
end
184+
else
185+
blanks = blanks + 1
186+
end
187+
else
188+
blanks = blanks + 1
189+
end
190+
if blanks > 50 then
191+
blanks = 0
192+
os.sleep(0) -- avoid too long without yielding
193+
end
194+
end
195+
end
196+
end
197+
198+
return M

0 commit comments

Comments
 (0)