From fecae09d68d883add4041f54333ad555513d0298 Mon Sep 17 00:00:00 2001 From: Guillxer <40972780+guillxer@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:34:03 -0500 Subject: [PATCH] Thumby Raytrace Shadows Python emulation and MPY hardware versions of raytracing with shadows. A link to a faster C++ version is included in the description. --- DemoRT/DemoRT.py | 259 ++++++++++++++++++++++++++++++++++ DemoRT/DemoRT_video.webm | Bin 0 -> 5482 bytes DemoRT/arcade_description.txt | 16 +++ DemoRT/thumbyrt.mpy | Bin 0 -> 12110 bytes 4 files changed, 275 insertions(+) create mode 100644 DemoRT/DemoRT.py create mode 100644 DemoRT/DemoRT_video.webm create mode 100644 DemoRT/arcade_description.txt create mode 100644 DemoRT/thumbyrt.mpy diff --git a/DemoRT/DemoRT.py b/DemoRT/DemoRT.py new file mode 100644 index 0000000..6a68eeb --- /dev/null +++ b/DemoRT/DemoRT.py @@ -0,0 +1,259 @@ +import thumby +import math +import random +import time +import sys +import machine +import array + +sys.path.append("/Games/DemoRT") + +emulator = None +try: + import emulator +except ImportError: + pass + +if not emulator: + import thumbyrt + +machine.freq(270000000) +thumby.display.setFPS(30) + +lightpos = [0, -800, -1500] +nativewidth = 72 +nativeheight = 40 + +colorlist = [0] * nativewidth * nativeheight +arr = array.array('B', colorlist) + +############################################# + +class vec3: + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + + def __add__(self, a): + if isinstance(a, self.__class__): + return vec3(self.x + a.x, self.y + a.y, self.z + a.z) + elif isinstance(a, float): + return vec3(self.x + a, self.y + a, self.z + a) + else: + return self + + def __sub__(self, a): + if isinstance(a, self.__class__): + return vec3(self.x - a.x, self.y - a.y, self.z - a.z) + elif isinstance(a, float): + return vec3(self.x - a, self.y - a, self.z - a) + else: + return self + + def __mul__(self, a): + if isinstance(a, self.__class__): + return vec3(self.x * a.x, self.y * a.y, self.z * a.z) + elif isinstance(a, float): + return vec3(self.x * a, self.y * a, self.z * a) + else: + return self + + def __truediv__(self, a): + if isinstance(a, self.__class__): + return vec3(self.x / a.x, self.y / a.y, self.z / a.z) + elif isinstance(a, float): + return vec3(self.x / a, self.y / a, self.z / a) + else: + return self + + def __neg__(self): + return vec3(-self.x, -self.y, -self.z) + + def dot(self, a): + return self.x * a.x + self.y * a.y + self.z * a.z + + def getnormalized(self): + mag = math.sqrt(self.dot(self)) + if (mag == 0.0): + return self + return self/mag + +class Ray: + def __init__(self, origin, direction): + self.origin = origin + self.direction = direction.getnormalized() + + def point_at_parameter(self, t): + return self.origin + self.direction * t + +class Sphere: + def __init__(self, acenter, aradius): + self.center = acenter + self.radius = aradius + + def intersect(self, ray): + oc = ray.origin - self.center + a = ray.direction.dot(ray.direction) + b = 2.0 * oc.dot(ray.direction) + c = oc.dot(oc) - self.radius * self.radius + discriminant = b * b - 4.0 * a * c + if (discriminant < 0.0): + return [False, 0.0] + else: + t = (-b - math.sqrt(discriminant)) / (2.0 * a) + return [True, t] + +class Plane: + def __init__(self, aposition, anormal): + self.position = aposition + self.normal = anormal + + def intersect(self, ray): + denom = self.normal.dot(ray.direction) + if (denom > 0.0001): + p0l0 = self.position - ray.origin + t = p0l0.dot(self.normal) / denom + if t >= 0.0: + return [True, t] + else: + return [False, 0.0] + return [False, 0.0] + +@micropython.native +def visiblecheck(ray, spheres, numspheres, minlength): + bvis = True + for i in range(0, numspheres, 1): + t = 0.0 + bintersect, t = spheres[i].intersect(ray) + if bintersect: + if t <= minlength: + bvis = False + return bvis + + +def find_closest(x, y, c0): + dither44 = [ + [ 0.0 / 16.0, 12.0 / 16.0, 3.0 / 16.0, 15.0 / 16.0 ], + [ 8.0 / 16.0, 4.0 / 16.0, 11.0 / 16.0, 7.0 / 16.0 ], + [ 2.0 / 16.0, 14.0 / 16.0, 1.0 / 16.0, 13.0 / 16.0 ], + [ 10.0 / 16.0, 6.0 / 16.0, 9.0 / 16.0, 5.0 / 16.0 ]] + + limit = 0.0 + if x < 4: + limit = dither44[x][y] + if c0 <= limit: + return 0.0 + return 1.0 + +def dithershadeintodisplay(): + for x in range(0, nativewidth, 1): + for y in range(0, nativeheight, 1): + shadeestimate = arr[x * nativeheight + y] + color = find_closest(x % 4, y % 4, shadeestimate / 255.0) + color = color * 255.0 + if (int(color) > 0): + thumby.display.setPixel(x, y, 1); + + +#@micropython.native +def raytracetest(): + aspect = nativewidth / nativeheight + + lightpospy = vec3(lightpos[0], lightpos[1], lightpos[2]) * 0.001 + + visbias = 0.001 + lightsphere = Sphere(lightpospy, 0.1) + sphere0 = Sphere(vec3(0.5, 0.0, -1.0), 0.5) + sphere1 = Sphere(vec3(-0.8, 0.0, -1.5), 0.5) + spheres = [lightsphere, sphere0, sphere1] + visspheres0 = [ sphere1 ] + visspheres1 = [ sphere0 ] + visspheres2 = [ sphere0, sphere1 ] + visspheres = [ None, visspheres0, visspheres1, visspheres2 ] + visspheresnum = [0,1,1,2] + numspheres = 3 + + for j in range(0, nativeheight, 1): + for i in range(0, nativewidth, 1): + u = float(i) / float(nativewidth) + v = float(j) / float(nativeheight) + ray = Ray(vec3(0.0, 0.0, 0.0), vec3((2.0 * u - 1.0 ) * aspect, 2.0 * v - 1.0, -1.0)) + + r = 0 + mint = 1000.0 + for spherei in range(0, numspheres, 1): + bintersect, t = spheres[spherei].intersect(ray) + if bintersect: + if (spherei == 0): + if (t < mint): + mint = t + r = 255 + else: + if (t < mint): + point = ray.point_at_parameter(t) + normal = (point - spheres[spherei].center).getnormalized(); + lightvec = -(point - lightpospy).getnormalized(); + length = math.sqrt((point - lightpospy).dot(point - lightpospy)) + visray = Ray(point + normal * visbias, lightvec) + mint = t + if (visiblecheck(visray, visspheres[spherei], visspheresnum[spherei], length)): + color = (lightvec.dot(normal) + 1.0) / 2.0 + r = int(255.99 * color) + + plane0 = Plane(vec3(0.0, 1.0, 0.0), vec3(0.0, 1.0, 0.0)) + bintersect, t = plane0.intersect(ray) + if bintersect: + if (t < mint): + point = ray.point_at_parameter(t) + normal = plane0.normal + lightvec = (point - lightpospy).getnormalized() + length = math.sqrt((point - lightpospy).dot(point - lightpospy)) + visray = Ray(point + normal * visbias, lightvec) + mint = t + if (visiblecheck(visray, visspheres[3], visspheresnum[3], length)): + color = (lightvec.dot(normal) + 1.0) / 2.0 + r = int(255.99 * color) + + color = int(r) + arr[i * nativeheight + j] = color + dithershadeintodisplay() + + + +@micropython.native +def Main(): + while True: + thumby.display.fill(0) + if not emulator: + # Call into thumbyRT module for raytracing a simple scene + thumbyrt.productf(lightpos[0], lightpos[1], lightpos[2], arr) + # This looping over an array is very slow in micro python + for x in range(nativewidth): + for y in range(nativeheight): + thumby.display.setPixel(x, y, arr[x * nativeheight + y] & 0x1) + else: + raytracetest() + + moveamount = 100 + + if thumby.buttonU.pressed(): + lightpos[2] -= moveamount + if thumby.buttonD.pressed(): + lightpos[2] += moveamount + if thumby.buttonL.pressed(): + lightpos[0] -= moveamount + if thumby.buttonR.pressed(): + lightpos[0] += moveamount + if thumby.buttonA.pressed(): + lightpos[1] -= moveamount + if thumby.buttonB.pressed(): + lightpos[1] += moveamount + + lightpos[2] = min(lightpos[2], -500) + + thumby.display.update() + +Main() + + diff --git a/DemoRT/DemoRT_video.webm b/DemoRT/DemoRT_video.webm new file mode 100644 index 0000000000000000000000000000000000000000..bd18e2163e8e8593df087c84f2f7fcab71a93798 GIT binary patch literal 5482 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 literal 0 HcmV?d00001 diff --git a/DemoRT/arcade_description.txt b/DemoRT/arcade_description.txt new file mode 100644 index 0000000..05d5d20 --- /dev/null +++ b/DemoRT/arcade_description.txt @@ -0,0 +1,16 @@ +[DemoRT] + +A simple raytracing shadow demo written in python and C compiled as an mpy module. +When running in the emulator a separate reference python only implementation is used. +When running on hardware the single threaded mpy implementation is used to fill the thumby display buffer. +Mpy uses a 16.16 fixed precision and supports non-firmware unsigned 64 bit and 32 bit floating point oparations with assembly and c implementations. + +To see the mpy implementation, go to the below github and refer to the mpy build instructions using v1.19.1 of micropython. +https://github.com/guillxer/DemoRT_MPY + +To see a pure two core C++ implementation using the Arduino and Thumby SDKs go to this github: +https://github.com/guillxer/DemoRT_C +Compile with Arduino SDK or install DemoRT.uf2 directly. + +Author: Will DiSanto +Version: 1.0 \ No newline at end of file diff --git a/DemoRT/thumbyrt.mpy b/DemoRT/thumbyrt.mpy new file mode 100644 index 0000000000000000000000000000000000000000..983e873ae9c86660e2ec8835f9550856b4e147a8 GIT binary patch literal 12110 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 m?o>reYhJo16uT*ITikR{Y20_63eL=Gf2PDY