Raymarch 初步

This commit is contained in:
alcoholicgirl
2026-04-25 00:26:05 +08:00
parent 87e5029be0
commit 2c1709a77d
11 changed files with 1607 additions and 1056 deletions

3
.gitignore vendored
View File

@ -24,3 +24,6 @@ __pycache__
/.venv
/venv
*.egg-info
# OS 相关
.DS_Store

View File

@ -0,0 +1,160 @@
"""
raymarch toy
usage: march <scene1> <scene2> ... <sceneN>
<scene> ::= <scene> "." <command>
| <command>
<command> ::= <id>
| <id> "(" <args> ")"
example:
march cube.pos(0, 0, 0).color(red) cam(1.0).pos(1, 1, 1).lookat(0, 0, 0)
"""
import pathlib
from dataclasses import dataclass
import re
import numpy as np
from dataclasses import dataclass
from obj import Object, Camera, OBJECT_ENTRIES
from op import OPERATION_ENTRIES
from typing import Optional
"""
# cmd = on_command("march", priority=10, block=True)
# @cmd.handle()
# async def _(args: Message = CommandArg()):
# if inst_text := args.extract_plain_text():
# instructions = inst_text.split(' ')
# for instruction in instructions:
# if instruction == '': continue
# if parsed_inst := parse_instruction(instruction):
# pass
"""
@dataclass
class Command:
id: str
args: list[str]
class CommandChainParser:
CHAIN_PATTERN = r"^[a-zA-Z]+(\([^(]*\))?(\.[a-zA-Z]+(\([^(]*\))?)+"
def __init__(self, _command_chain: str) -> None:
self.command_chain = _command_chain
def __iter__(self):
return self
def __next__(self):
if query := re.match(CommandChainParser.CHAIN_PATTERN, self.command_chain):
cmd_chain = query[0]
self.command_chain = self.command_chain[len(cmd_chain) + 1 :]
return cmd_chain
raise StopIteration
class CommandParser:
CMD_PATTERN = r"^[a-zA-Z]+(\([^(]*\))?"
ID_PATTERN = r"^[a-zA-Z]+(?=\(|\.|$)"
def __init__(self, _command: str) -> None:
self.command = _command
def __iter__(self):
return self
def __next__(self):
if query := re.match(CommandParser.CMD_PATTERN, self.command):
cmd = query[0]
if cmd_id_qry := re.match(CommandParser.ID_PATTERN, cmd):
cmd_id = cmd_id_qry[0]
self.command = self.command[len(cmd) + 1 :]
cmd_args = cmd[len(cmd_id) + 1 : -1].replace(" ", "").split(",")
while "" in cmd_args:
cmd_args.remove("")
return Command(cmd_id, cmd_args)
raise StopIteration
class Scene:
def __init__(self, _instruction: str) -> None:
self.canvas_objs: list[tuple[Object, str]] = []
self.camera: Camera = Camera()
for raw_cmd in CommandChainParser(_instruction):
cmd_queue = CommandParser(raw_cmd)
cmd_obj = next(cmd_queue)
obj_id, obj_args = cmd_obj.id, cmd_obj.args
obj_instance: Optional[Object] = None
if obj_id in OBJECT_ENTRIES:
obj_cls = OBJECT_ENTRIES[obj_id]
if not issubclass(obj_cls, Object):
raise Exception(f"{obj_id} is not a subclass of Object.")
obj_instance = obj_cls()
try:
if len(obj_args) != 0:
obj_instance.parse_args(obj_args)
except Exception as e:
raise Exception(
f"object {obj_id} failed to parse args passed in: {obj_args}"
) from e
else:
raise Exception(f"{obj_id} is not a valid object type.")
if obj_instance != None:
for cmd in cmd_queue:
op_id, op_args = cmd.id, cmd.args
if op_id in OPERATION_ENTRIES:
op_func = OPERATION_ENTRIES[op_id]
if not callable(op_func):
raise Exception(f"{op_id} is not a valid operation.")
op_func(obj_instance, op_args)
else:
raise Exception(f"{op_id} is not a valid operation.")
# 判断是否可以绘制,有点蹩脚但是省事
try:
sdf_block = obj_instance.sdf_block()
self.canvas_objs.append((obj_instance, sdf_block))
except:
if type(obj_instance) == Camera:
self.camera = obj_instance
def __str__(self) -> str:
return ", ".join([str(type(obj)) for obj in self.canvas_objs])
def compile(self) -> str:
path = (
pathlib.Path("konabot")
/ "plugins"
/ "sksl"
/ "marchtoy"
/ "shaders"
/ "raymarch.sksl"
)
with path.open(encoding="utf-8") as f:
content = f.read()
sdf_block: str = ""
index = 0
for canvas_item in self.canvas_objs:
sdf_block += f"float sd{index} = {canvas_item[1]};"
sdf_block += f"if(qry.value < sd{index})"
sdf_block += "{" + f"qry.value = sd{index}; qry.obj_id = {index}; " + "}\n"
index += 1
content = content.replace("<SDF_BLOCK>", sdf_block)
cam_pos = self.camera.transform.t[0:3, 3]
cam_focus = self.camera.focus
cam_dir = self.camera.transform.t @ np.array((0.0, 0.0, -1.0, 0.0))
content = content.replace("<CAM_POS>", f"{cam_pos[0]}, {cam_pos[1]}, {cam_pos[2]}")
content = content.replace("<CAM_FOCUS>", str(cam_focus))
content = content.replace("<CAM_DIR>", f"{cam_dir[0]}, {cam_dir[1]}, {cam_dir[2]}")
return content

View File

@ -0,0 +1,33 @@
from pathlib import Path
from .obj import Scene
from .utilities import format_float, format_float3
TEMPLATE_PATH = Path(__file__).parent / "passes" / "pass1.sksl"
TEMPLATE = TEMPLATE_PATH.read_text("utf-8")
def compile_scene_to_shader(scene: Scene) -> str:
object_blocks = "\n".join(
obj.compile_block(index)
for index, obj in enumerate(scene.objects, start=1)
)
return (
TEMPLATE.replace("/*<SCENE_OBJECTS>*/", object_blocks)
.replace("/*<CAM_POS>*/", ", ".join(format_float(v) for v in scene.camera.position))
.replace(
"/*<CAM_TARGET>*/",
", ".join(format_float(v) for v in scene.camera.target),
)
.replace("/*<CAM_FOV>*/", format_float(scene.camera.fov))
.replace(
"/*<LIGHT_DIR>*/",
", ".join(format_float(v) for v in scene.light.direction),
)
.replace(
"/*<GAMMA>*/",
format_float3((0.454545, 0.454545, 0.454545)),
)
)

View File

@ -0,0 +1,123 @@
import numpy as np
from texture import Texture
from utilities import ArgParser, SkslFormatter
OBJECT_ENTRIES = {}
def make_obj(name: str, aliases: list[str] = []):
def decorator(cls):
OBJECT_ENTRIES[name] = cls
for alias in aliases:
OBJECT_ENTRIES[id] = cls
return cls
return decorator
class Transform:
def __init__(self) -> None:
self.t: np.ndarray = np.identity(4, dtype=np.float32)
@staticmethod
def normalize(p: np.ndarray) -> np.ndarray:
return p / np.sqrt(np.dot(p, p))
def translate(self, x: float, y: float, z: float):
mat = np.identity(4, dtype=np.float32)
mat[0:3, 3] = [x, y, z]
self.t = mat @ self.t
return self
# scale 会破坏 sdf 的性质,不应该使用
def scale(self, x: float, y: float, z: float):
mat = np.identity(4, dtype=np.float32)
mat[0, 0], mat[1, 1], mat[2, 2] = x, y, z
self.t = mat @ self.t
return self
def rotate(self, x: float, y: float, z: float):
cx, sx = np.cos(x), np.sin(x)
cy, sy = np.cos(y), np.sin(y)
cz, sz = np.cos(z), np.sin(z)
mat = np.identity(4, dtype=np.float32)
mat[0, 0] = cy * cz
mat[0, 1] = sx * sy * cz - cx * sz
mat[0, 2] = cx * sy * cz + sx * sz
mat[1, 0] = cy * sz
mat[1, 1] = sx * sy * sz + cx * cz
mat[1, 2] = cx * sy * sz - sx * cz
mat[2, 0] = -sy
mat[2, 1] = sx * cy
mat[2, 2] = cx * cy
self.t = mat @ self.t
return self
def lookat(
self, x: float, y: float, z: float, up: np.ndarray = np.array([0.0, 1.0, 0.0])
):
p = self.t[0:3, 3]
q = np.array([x, y, z])
yaxis = up
zaxis = Transform.normalize(q - p)
xaxis = Transform.normalize(np.cross(zaxis, yaxis))
yaxis = Transform.normalize(np.cross(xaxis, zaxis))
V = np.array([xaxis, yaxis, zaxis])
self.t[:3, :3] = V
return self
def p_expr(self) -> str:
inv = np.linalg.inv(self.t) # + 1e-5 * np.identity(4, dtype=np.float32))
return f"({SkslFormatter.mat4(inv)} * float4(p, 1.0)).xyz"
class Object:
def __init__(self) -> None:
self.transform: Transform = Transform()
self.texture: Texture = Texture()
def parse_args(self, args: list[str]):
raise NotImplementedError
def sdf_block(self) -> str:
raise NotImplementedError
@make_obj("cube")
class Cube(Object):
def __init__(self, _size: np.ndarray = np.array([1.0, 1.0, 1.0])) -> None:
super().__init__()
self.size = _size
def parse_args(self, args: list[str]):
self.size = ArgParser.as_vec3(args)
def sdf_block(self) -> str:
return f"sdCube({self.transform.p_expr()}, float3({self.size[0]}, {self.size[1]}, {self.size[2]}))"
@make_obj("sphere")
class Sphere(Object):
def __init__(self, _radius: float = 1.0) -> None:
super().__init__()
self.radius = _radius
def parse_args(self, args: list[str]):
self.radius = ArgParser.as_float(args)
def sdf_block(self) -> str:
return f"sdSphere({self.transform.p_expr()}, {self.radius})"
@make_obj("camera")
class Camera(Object):
def __init__(self, _focus: float = 1.0) -> None:
super().__init__()
self.focus = _focus
def parse_args(self, args: list[str]):
self.focus = ArgParser.as_float(args)

View File

@ -0,0 +1,47 @@
from obj import Object
import numpy as np
from utilities import ArgParser
OPERATION_ENTRIES = {}
def make_operation(name: str, aliases: list[str] = []):
def decorator(op):
OPERATION_ENTRIES[name] = op
for alias in aliases:
OPERATION_ENTRIES[alias] = op
return op
return decorator
@make_operation("pos", ["translate", "position", "p"])
def translate(obj: Object, args: list[str]):
pos = ArgParser.as_vec3(args)
obj.transform.translate(pos[0], pos[1], pos[2])
@make_operation("rot", ["rotate", "r"])
def rotate(obj: Object, args: list[str]):
pos = ArgParser.as_vec3(args)
obj.transform.rotate(pos[0], pos[1], pos[2])
@make_operation("lookat", ["look", "l"])
def lookat(obj: Object, args: list[str]):
pos = ArgParser.as_vec3(args)
obj.transform.lookat(pos[0], pos[1], pos[2])
@make_operation("color", ["col", "texture"])
def color(obj: Object, args: list[str]):
try:
if len(args) == 3:
col = ArgParser.as_vec3(args)
obj.texture.color = (col[0], col[1], col[2], 1.0)
elif len(args) == 4:
col = ArgParser.as_vec4(args)
obj.texture.color = (col[0], col[1], col[2], col[4])
except:
raise Exception("unknown color")

View File

@ -0,0 +1,64 @@
uniform float u_time;
uniform float2 u_resolution;
const float EPS = 0.001;
const float MAX_ITER = 64;
struct sdQuery {
float value;
int obj_id;
}
float sdCube(float3 p, float3 b) {
p = abs(p) - b;
return length(max(p, 0.0)) + min(max(p.x, max(p.y, p.z)), 0.0);
}
float sdSphere(float3 p, float r) {
return length(p) - r
}
sdQuery sd(float3 p) {
sdQuery qry;
qry.value = 100000000.0;
<SDF_BLOCK>
}
float3 nrm(float3 p) {
float2 d = float2(EPS, 0.0);
return normalize(float4(
sd(p + d.xyy) - sd(p),
sd(p + d.yxy) - sd(p),
sd(p + d.yyx) - sd(p),
));
}
float4 color(float3 p) {
return float4(nrm(p), 1.0);
}
float4 march(float3 p, float3 r) {
sdQuery qry;
float4 col;
for(int i = 0; i < MAX_ITER; ++i) {
qry = sd(p);
if(qry.value < EPS){
col = color(p);
break;
}
p += qry.value * r;
}
return col;
}
half4 main(float2 fragCoord) {
float2 uv = -2. * (fragCoord / u_resolution - .5) * float2(u_resolution.x / u_resolution.y, 1.);
float3 c_p = float3(<CAM_POS>);
float3 c_z = normalize(float3(<CAM_DIR>));
float3 c_y = float3(0., 1., 0.);
float3 c_x = normalize(cross(c_z, c_y));
float3x3 view = float3x3(c_x, c_y, c_z);
float3 r = normalize(float3(u, v, <CAM_FOCUS>));
return half4(march(c_p, view * r));
}

View File

@ -0,0 +1,13 @@
COLORS = {
"red": (1.0, 0.0, 0.0, 1.0),
"green": (0.0, 1.0, 0.0, 1.0),
"blue": (0.0, 0.0, 1.0, 1.0),
"white": (1.0, 1.0, 1.0, 1.0),
}
from dataclasses import dataclass
@dataclass
class Texture:
color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)

View File

@ -0,0 +1,67 @@
import numpy as np
from texture import COLORS
class SkslFormatter:
@staticmethod
def mat4(m: np.ndarray) -> str:
if m.shape != (4, 4):
m = np.identity(4)
v_0 = ", ".join([str(x) for x in m[0]])
v_1 = ", ".join([str(x) for x in m[1]])
v_2 = ", ".join([str(x) for x in m[2]])
v_3 = ", ".join([str(x) for x in m[3]])
return f"float4x4(float4({v_0}), float4({v_1}), float4({v_2}), float4({v_3}))"
class ArgParser:
@staticmethod
def as_float(args: list[str], default: float = 0.0) -> float:
try:
if len(args) >= 1:
x = float(args[0])
return x
except:
raise Exception(f"cannot parse {args}")
return default
@staticmethod
def as_vec3(
args: list[str], default: np.ndarray = np.array((0.0, 0.0, 0.0))
) -> np.ndarray:
try:
if len(args) == 1:
x = float(args[0])
return np.array((x, x, x))
elif len(args) == 3:
x = float(args[0])
y = float(args[1])
z = float(args[2])
return np.array((x, y, z))
except:
raise Exception(f"cannot parse {args}")
return default
@staticmethod
def as_vec4(
args: list[str], default: np.ndarray = np.array((0.0, 0.0, 0.0, 0.0))
) -> np.ndarray:
try:
if len(args) == 1:
x = float(args[0])
return np.array((x, x, x))
elif len(args) == 3:
x = float(args[0])
y = float(args[1])
z = float(args[2])
w = float(args[3])
return np.array((x, y, z, w))
except:
raise Exception(f"cannot parse {args}")
return default
@staticmethod
def as_literal_color(args: list[str], default=np.array((1.0, 1.0, 1.0, 1.0))):
if len(args) == 1 and args[0] in COLORS:
return np.array(COLORS[args[0]])
return default

View File

@ -24,7 +24,6 @@ def _pack_uniforms(uniforms_dict, width, height, time_val):
# 移除填充字节,使用紧凑布局
return time_bytes + res_bytes
def _render_sksl_shader_to_gif(
sksl_code: str,
width: int = 256,
@ -152,4 +151,4 @@ async def render_sksl_shader_to_gif(
height,
duration,
fps,
)
)

2148
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,6 @@ dependencies = [
"imagetext-py (>=2.2.0,<3.0.0)",
"opencv-python-headless (>=4.12.0.88,<5.0.0.0)",
"returns (>=0.26.0,<0.27.0)",
"skia-python (>=138.0,<139.0)",
"nonebot-plugin-analysis-bilibili (>=2.8.1,<3.0.0)",
"qrcode (>=8.2,<9.0)",
"nanoid (>=2.0.0,<3.0.0)",
@ -39,6 +38,7 @@ dependencies = [
"pytest-cov (>=7.0.0,<8.0.0)",
"aiosignal (>=1.4.0,<2.0.0)",
"pytest-mock (>=3.15.1,<4.0.0)",
"skia-python (>=144.0.post2,<145.0)",
]
[tool.poetry]