diff --git a/konabot/docs/user/march.txt b/konabot/docs/user/march.txt index 2f23961..b2b3dae 100644 --- a/konabot/docs/user/march.txt +++ b/konabot/docs/user/march.txt @@ -1,22 +1,18 @@ # 指令介绍 简易 Raymarch 小玩具 -用法:march +用法:march `` 例:march sphere(1).color(red) box(0.5, 2.0, 0.5).pos(0, 0, 0) cam(0.5).pos(-5).lookat(0) # 主要语法 - ::= "." - | +`` ::= `` "." `` |`` - ::= - | "(" ")" +`` ::= `` | `` "(" ")" - ::= - | "(" ")" +`` ::= `` | `` "(" `` ")" - ::= "," - | +`` ::= `` "," `` | `` -其中 obj_ty、op_ty 分别为物体类型(如 cube、sphere、torus 等)与变换类型(如 pos、rot)。 +其中 `obj_ty`、`op_ty` 分别为物体类型(如 `cube`、`sphere`、`torus` 等)与变换类型(如 `pos`、`rot`)。 # 特殊说明 - 不包含 scale,因为本工具基于 SDF 渲染,非正交的变换会破坏 SDF 的性质。 \ No newline at end of file +`` 不包含 scale。非正交的变换会破坏 SDF 的性质。 \ No newline at end of file diff --git a/konabot/plugins/marchtoy/__init__.py b/konabot/plugins/marchtoy/__init__.py index 7e48840..68acf25 100644 --- a/konabot/plugins/marchtoy/__init__.py +++ b/konabot/plugins/marchtoy/__init__.py @@ -3,6 +3,7 @@ from nonebot.adapters import Message from nonebot_plugin_alconna import UniMessage from nonebot.params import CommandArg import konabot.plugins.marchtoy.gl_render as render +# import konabot.plugins.marchtoy.cpu_render as render import io cmd_marchtoy = on_command("march") @cmd_marchtoy.handle() diff --git a/konabot/plugins/marchtoy/command.py b/konabot/plugins/marchtoy/command.py index 88e8aa9..005e54b 100644 --- a/konabot/plugins/marchtoy/command.py +++ b/konabot/plugins/marchtoy/command.py @@ -19,9 +19,9 @@ import re import numpy as np from dataclasses import dataclass from typing import Optional + from konabot.plugins.marchtoy.obj import Object, Camera, OBJECT_ENTRIES from konabot.plugins.marchtoy.op import OPERATION_ENTRIES - @dataclass class Command: id: str @@ -103,7 +103,7 @@ class Scene: raise Exception(f"{op_id} is not a valid operation.") try: - sdf_block = obj_instance.sdf_block() + sdf_block = obj_instance.sdf_block_glsl() self.canvas_objs.append((obj_instance, sdf_block)) except: if type(obj_instance) == Camera: @@ -127,7 +127,7 @@ class Scene: sdf_block += "{" + f"qry.value = sd{index}; qry.obj_id = {index}; " + "}\n" color = obj.texture.color color_block += ( - f"if(obj_id == {index}) return float4(" + f"if(obj_id == {index}) return vec4(" f"{color[0]}, {color[1]}, {color[2]}, {color[3]});\n" ) index += 1 diff --git a/konabot/plugins/marchtoy/cpu_render.py b/konabot/plugins/marchtoy/cpu_render.py new file mode 100644 index 0000000..4836d46 --- /dev/null +++ b/konabot/plugins/marchtoy/cpu_render.py @@ -0,0 +1,109 @@ +from typing import Optional + +import numpy as np +from PIL import Image + +from konabot.plugins.marchtoy.command import Scene +from konabot.plugins.marchtoy.obj import Object, Transform + + +class SDQuery: + def __init__(self, _value: float = np.inf, _obj: Optional[Object] = None): + self.value: float = _value + self.obj: Optional[Object] = _obj + + +class Rasterizer: + EPS = 0.001 + MAX_ITER = 128 + MAX_DIST = 128.0 + + def __init__(self, _scene: Scene, _res: tuple[int, int]): + self.scene = _scene + self.width, self.height = _res + self.fb_rgba = np.zeros((self.height, self.width, 4), dtype=np.float32) + + def sdf(self, p: np.ndarray) -> SDQuery: + qry = SDQuery() + for obj, _ in self.scene.canvas_objs: + sd = obj.sdf(p) + if sd < qry.value: + qry.value = sd + qry.obj = obj + return qry + + def nrm(self, p: np.ndarray) -> np.ndarray: + dx = np.array((self.EPS, 0.0, 0.0)) + dy = np.array((0.0, self.EPS, 0.0)) + dz = np.array((0.0, 0.0, self.EPS)) + grad = np.array( + [ + self.sdf(p + dx).value - self.sdf(p - dx).value, + self.sdf(p + dy).value - self.sdf(p - dy).value, + self.sdf(p + dz).value - self.sdf(p - dz).value, + ], + dtype=np.float32, + ) + return Transform.normalize(grad) + + def color(self, p: np.ndarray, obj: Object) -> np.ndarray: + normal = self.nrm(p) + light_dir = Transform.normalize(np.array((0.5, 0.8, -0.6), dtype=np.float32)) + light = 0.2 + 0.8 * max(float(np.dot(normal, light_dir)), 0.0) + base = np.array(obj.texture.color, dtype=np.float32) + return np.array((base[0] * light, base[1] * light, base[2] * light, base[3])) + + def march(self, origin: np.ndarray, direction: np.ndarray) -> np.ndarray: + p = origin.copy() + travel = 0.0 + + for _ in range(self.MAX_ITER): + qry = self.sdf(p) + if not np.isfinite(qry.value): + break + if qry.value < self.EPS and qry.obj is not None: + return self.color(p, qry.obj) + if qry.value > self.MAX_DIST or travel > self.MAX_DIST: + break + p += direction * qry.value + travel += qry.value + + return np.zeros(4, dtype=np.float32) + + def camera_basis(self) -> tuple[np.ndarray, np.ndarray]: + cam = self.scene.camera + cam_pos = cam.transform.t[0:3, 3] + cam_z = Transform.normalize( + (cam.transform.t @ np.array((0.0, 0.0, -1.0, 0.0), dtype=np.float32))[:3] + ) + world_up = ( + np.array((0.0, 0.0, 1.0), dtype=np.float32) + if abs(cam_z[1]) > 0.999 + else np.array((0.0, 1.0, 0.0), dtype=np.float32) + ) + cam_x = Transform.normalize(np.cross(cam_z, world_up)) + cam_y = Transform.normalize(np.cross(cam_x, cam_z)) + view = np.column_stack((cam_x, cam_y, cam_z)) + return cam_pos, view + + def rasterize(self) -> np.ndarray: + cam_pos, view = self.camera_basis() + resolution = np.array((self.width, self.height), dtype=np.float32) + aspect = np.array((self.width / self.height, 1.0), dtype=np.float32) + cam_focus = float(self.scene.camera.focus) + + for y, x in np.ndindex(self.height, self.width): + frag_coord = np.array((x + 0.5, y + 0.5), dtype=np.float32) + uv = 2.0 * (frag_coord / resolution - 0.5) * aspect + ray_local = Transform.normalize(np.array((uv[0], uv[1], cam_focus))) + ray_world = Transform.normalize(view @ ray_local) + self.fb_rgba[y, x] = self.march(cam_pos, ray_world) + + return self.fb_rgba + + +async def render(command: str, res: tuple[int, int]): + rasterizer = Rasterizer(Scene(command), res) + fb_output = rasterizer.rasterize() + rgba = np.clip(fb_output * 255.0, 0.0, 255.0).astype(np.uint8) + return Image.fromarray(rgba, mode="RGBA") diff --git a/konabot/plugins/marchtoy/gl_render.py b/konabot/plugins/marchtoy/gl_render.py index 202ea2a..33553b4 100644 --- a/konabot/plugins/marchtoy/gl_render.py +++ b/konabot/plugins/marchtoy/gl_render.py @@ -12,7 +12,7 @@ async def render(command: str, res: tuple[int, int]): PATH = pathlib.Path(__file__).parent / "shaders" with (PATH / "vert.glsl").open(encoding='utf-8') as f: vs = f.read() - ctx = moderngl.create_context(standalone=True) + ctx = moderngl.create_context(standalone=True, backend='egl') ctx.gc_mode = "auto" try: program = ctx.program( @@ -32,4 +32,5 @@ async def render(command: str, res: tuple[int, int]): fbo.use() fbo.clear(0.0, 0.0, 0.0, 0.0) vao.render(moderngl.TRIANGLES) - return Image.frombytes('RGBA', fbo.size, fbo.read(components=4), 'raw', 'RGBA', 0, -1) \ No newline at end of file + return Image.frombytes('RGBA', fbo.size, fbo.read(components=4), 'raw', 'RGBA', 0, -1) + diff --git a/konabot/plugins/marchtoy/obj.py b/konabot/plugins/marchtoy/obj.py index 58512d5..366d383 100644 --- a/konabot/plugins/marchtoy/obj.py +++ b/konabot/plugins/marchtoy/obj.py @@ -72,7 +72,7 @@ class Transform: def p_expr(self) -> str: inv = np.linalg.inv(self.t) # + 1e-5 * np.identity(4, dtype=np.float32)) - return f"({Formatter.float4(inv)} * vec4(p, 1.0)).xyz" + return f"({Formatter.vec4(inv)} * vec4(p, 1.0)).xyz" class Object: @@ -83,9 +83,20 @@ class Object: def parse_args(self, args: list[str]): raise NotImplementedError - def sdf_block(self) -> str: + def get_transformed(self, p: np.ndarray) -> np.ndarray: + if p.shape != (3,): + raise Exception(f"{p} is not a vec3") + p = p.copy() + inv = np.linalg.inv(self.transform.t) + + return (inv @ np.array((*p, 1)))[:3] + + def sdf_block_glsl(self) -> str: raise NotImplementedError + def sdf(self, _p: np.ndarray) -> float: + return NotImplementedError + @make_obj("cube", "box") class Cube(Object): @@ -96,9 +107,14 @@ class Cube(Object): def parse_args(self, args: list[str]): self.size = ArgParser.as_vec3(args) - def sdf_block(self) -> str: + def sdf_block_glsl(self) -> str: return f"sdCube({self.transform.p_expr()}, vec3({self.size[0]}, {self.size[1]}, {self.size[2]}))" + def sdf(self, _p): + p = self.get_transformed(_p) + p = np.abs(p) - self.size + return np.linalg.norm(np.maximum(p, 0.0)) + np.minimum(np.max(p), 0.0) + @make_obj("sphere", "ball") class Sphere(Object): @@ -109,9 +125,13 @@ class Sphere(Object): def parse_args(self, args: list[str]): self.radius = ArgParser.as_float(args) - def sdf_block(self) -> str: + def sdf_block_glsl(self) -> str: return f"sdSphere({self.transform.p_expr()}, {self.radius})" + def sdf(self, _p): + p = self.get_transformed(_p) + return np.linalg.norm(p) - self.radius + @make_obj("cylinder", "cyl") class Cylinder(Object): @@ -125,11 +145,20 @@ class Cylinder(Object): self.radius = param[0] self.height = param[1] - def sdf_block(self) -> str: + def sdf_block_glsl(self) -> str: return ( f"sdCappedCylinder({self.transform.p_expr()}, {self.radius}, {self.height})" ) + def sdf(self, _p): + p = self.get_transformed(_p) + d = np.abs(np.array([np.linalg.norm(p[[0, 2]]), p[1]])) - np.array( + [self.radius, self.height] + ) + return np.minimum(np.maximum(d[0], d[1]), 0.0) + np.linalg.norm( + np.maximum(d, 0.0) + ) + @make_obj("torus") class Torus(Object): @@ -143,24 +172,34 @@ class Torus(Object): self.r1 = param[0] self.r2 = param[1] - def sdf_block(self) -> str: + def sdf_block_glsl(self) -> str: return f"sdTorus({self.transform.p_expr()}, vec2({self.r1}, {self.r2}))" + def sdf(self, _p): + p = self.get_transformed(_p) + q = np.array([np.linalg.norm(p[[0, 2]]) - self.r1, p[1]]) + return np.linalg.norm(q) - self.r2 + @make_obj("capsule", "pill") class Capsule(Object): def __init__(self, _h: float = 1.0, _r: float = 0.25) -> None: super().__init__() - self._h = _h - self._r = _r + self.h = _h + self.r = _r def parse_args(self, args: list[str]): param = ArgParser.as_vec2(args) - self._h = param[0] - self._r = param[1] + self.h = param[0] + self.r = param[1] - def sdf_block(self) -> str: - return f"sdVerticalCapsule({self.transform.p_expr()}, {self._h}, {self._r})" + def sdf_block_glsl(self) -> str: + return f"sdVerticalCapsule({self.transform.p_expr()}, {self.h}, {self.r})" + + def sdf(self, _p): + p = self.get_transformed(_p) + p[1] -= np.clip(p[1], 0.0, self.h) + return np.linalg.norm(p) - self.r @make_obj("camera", "cam") diff --git a/konabot/plugins/marchtoy/shaders/frag.glsl b/konabot/plugins/marchtoy/shaders/frag.glsl index 34a12ac..a7b7fab 100644 --- a/konabot/plugins/marchtoy/shaders/frag.glsl +++ b/konabot/plugins/marchtoy/shaders/frag.glsl @@ -1,16 +1,8 @@ #version 330 -// compatibility -#define float4x4 mat4x4 -#define float4 vec4 -#define float3 vec3 -#define float2 vec2 - const float EPS = 0.001; const int MAX_ITER = 128; - uniform vec2 u_resolution; out vec4 fragColor; - struct sdQuery { float value; int obj_id; diff --git a/konabot/plugins/marchtoy/skia_render.py b/konabot/plugins/marchtoy/skia_render.py index f04ef40..793ea53 100644 --- a/konabot/plugins/marchtoy/skia_render.py +++ b/konabot/plugins/marchtoy/skia_render.py @@ -1,4 +1,3 @@ -raise DeprecationWarning from command import Scene import skia import struct @@ -8,12 +7,12 @@ import numpy as np # 暂时先照抄小帕的了,之后有空再单独封装一下 -async def render(cmd: str, width: int, height: int): - scene = Scene(cmd) +async def render(instruction: str, res: tuple[int, int]): + scene = Scene(instruction) + width, height = res surface = skia.Surface(width, height) sksl_code = scene.compile() - - runtime_effect = skia.RuntimeEffect.MakeForShader(scene.compile()) + runtime_effect = skia.RuntimeEffect.MakeForShader(sksl_code) if runtime_effect is None: raise Exception("cannot compile sksl shader") diff --git a/konabot/plugins/marchtoy/utilities.py b/konabot/plugins/marchtoy/utilities.py index 7ff1b7e..34d18f7 100644 --- a/konabot/plugins/marchtoy/utilities.py +++ b/konabot/plugins/marchtoy/utilities.py @@ -3,7 +3,7 @@ from konabot.plugins.marchtoy.texture import COLORS class Formatter: @staticmethod - def float4(m: np.ndarray) -> str: + def float4x4(m: np.ndarray) -> str: if m.shape != (4, 4): m = np.identity(4) v_0 = ", ".join([str(x) for x in m[:, 0]]) diff --git a/tests/test_marchtoy_transform.py b/tests/test_marchtoy_transform.py deleted file mode 100644 index add962f..0000000 --- a/tests/test_marchtoy_transform.py +++ /dev/null @@ -1,17 +0,0 @@ -import sys -from pathlib import Path - - -PLUGIN_DIR = Path(__file__).resolve().parents[1] / "konabot" / "plugins" / "marchtoy" -if str(PLUGIN_DIR) not in sys.path: - sys.path.insert(0, str(PLUGIN_DIR)) - -from obj import Transform - - -def test_translate_expression_puts_offset_in_matrix_column(): - transform = Transform().translate(1.0, 2.0, 3.0) - - expr = transform.p_expr() - - assert "float4(-1.0, -2.0, -3.0, 1.0)" in expr