Compare commits

...

4 Commits

Author SHA1 Message Date
3bf0735af4 marchtoy 初步 2026-04-25 01:13:32 +08:00
b73d020b67 不小心多commit了个png文件 2026-04-25 00:58:50 +08:00
0eb957ba87 写通了,存个档 2026-04-25 00:58:20 +08:00
2c1709a77d Raymarch 初步 2026-04-25 00:26:05 +08:00
14 changed files with 1656 additions and 1066 deletions

3
.gitignore vendored
View File

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

View File

@ -0,0 +1,4 @@
# 指令介绍
简易 Raymarch 小玩具
用法march <scene>
march sphere(1) cam(4).pos(-5).lookat(0)

View File

@ -2,16 +2,23 @@ from nonebot_plugin_alconna import Alconna, Args, Option, UniMessage, on_alconna
from konabot.common.nb.exc import BotExceptionMessage
from konabot.plugins.sksl.run_sksl import render_sksl_shader_to_gif
from nonebot import on_command
from nonebot.adapters import Bot, Event, Message
from nonebot.params import CommandArg
import marchtoy
import io
cmd_run_sksl = on_alconna(
Alconna(
"shadertool",
Option("--width", Args["width_", int]),
Option("--height", Args["height_", int]),
Option("--duration", Args["duration_", float]),
Option("--fps", Args["fps_", float]),
Args["code", str],
)
)
cmd_run_sksl = on_alconna(Alconna(
"shadertool",
Option("--width", Args["width_", int]),
Option("--height", Args["height_", int]),
Option("--duration", Args["duration_", float]),
Option("--fps", Args["fps_", float]),
Args["code", str],
))
@cmd_run_sksl.handle()
async def _(
@ -34,10 +41,23 @@ async def _(
if width_ > 640 or height_ > 640:
raise BotExceptionMessage("最大支持 640x640 啦!不要太大啦!")
code = code.strip("\"").strip("'")
code = code.strip('"').strip("'")
try:
res = await render_sksl_shader_to_gif(code, width_, height_, duration_, fps_)
await cmd_run_sksl.send(await UniMessage().image(raw=res).export())
except (ValueError, RuntimeError) as e:
await cmd_run_sksl.send(await UniMessage().text(f"渲染时遇到了问题:\n\n{e}").export())
await cmd_run_sksl.send(
await UniMessage().text(f"渲染时遇到了问题:\n\n{e}").export()
)
cmd_marchtoy = on_command("march", priority=10, block=True)
@cmd_marchtoy.handle()
async def _(args: Message = CommandArg()):
if cmd := args.extract_plain_text():
img = await marchtoy.render.render(cmd, 256, 256)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
await cmd_run_sksl.send(await UniMessage().image(raw=buffer).export())

View File

@ -0,0 +1 @@
import render

View File

@ -0,0 +1,149 @@
"""
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
@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(__file__).parent / "shaders" / "raymarch.sksl"
with PATH.open(encoding="utf-8") as f:
content = f.read()
sdf_block: str = ""
color_block: str = ""
index = 0
for canvas_item in self.canvas_objs:
obj, sdf_expr = canvas_item
sdf_block += f"float sd{index} = {sdf_expr};"
sdf_block += f"if(sd{index} < qry.value)"
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"{color[0]}, {color[1]}, {color[2]}, {color[3]});\n"
)
index += 1
content = content.replace("<SDF_BLOCK>", sdf_block)
content = content.replace("<COLOR_BLOCK>", color_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,124 @@
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[alias] = 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])
zaxis = Transform.normalize(q - p)
xaxis = Transform.normalize(np.cross(zaxis, up))
yaxis = Transform.normalize(np.cross(xaxis, zaxis))
# 约定本地 -Z 为“朝前”,这样和 shader 中使用的相机射线方向保持一致。
self.t[:3, 0] = xaxis
self.t[:3, 1] = yaxis
self.t[:3, 2] = -zaxis
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", ["cam"])
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,52 @@
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) == 1:
col = ArgParser.as_literal_color(args)
obj.texture.color = (col[0], col[1], col[2], col[3])
elif 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[3])
else:
raise Exception("unknown color")
except:
raise Exception("unknown color")

View File

@ -0,0 +1,41 @@
from command import Scene
import skia
import struct
from PIL import Image
import numpy as np
# 暂时先照抄小帕的了,之后有空再单独封装一下
async def render(cmd: str, width: int, height: int):
scene = Scene(cmd)
surface = skia.Surface(width, height)
sksl_code = scene.compile()
runtime_effect = skia.RuntimeEffect.MakeForShader(scene.compile())
if runtime_effect is None:
raise Exception("cannot compile sksl shader")
uv_uniform = struct.pack("ff", float(width), float(height))
uniform_data = skia.Data.MakeWithCopy(uv_uniform)
shader = runtime_effect.makeShader(uniform_data, None, 0)
canvas = surface.getCanvas()
canvas.clear(skia.Color(0, 0, 0, 0))
paint = skia.Paint()
paint.setShader(shader)
canvas.drawRect(skia.Rect.MakeWH(width, height), paint)
image = surface.makeImageSnapshot()
target_info = skia.ImageInfo.Make(
image.width(),
image.height(),
skia.ColorType.kBGRA_8888_ColorType,
skia.AlphaType.kPremul_AlphaType,
)
pixel_data = bytearray(
image.width() * image.height() * 4
) # 4 bytes per pixel (BGRA)
success = image.readPixels(target_info, pixel_data, target_info.minRowBytes())
img_array = np.frombuffer(pixel_data, dtype=np.uint8).reshape((height, width, 4))
rgb_array = img_array[:, :, [2, 1, 0]]
pil_img = Image.fromarray(rgb_array)
return pil_img

View File

@ -0,0 +1,73 @@
uniform float2 u_resolution;
const float EPS = 0.001;
const int 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;
qry.obj_id = -1;
<SDF_BLOCK>
return qry;
}
float3 nrm(float3 p) {
float2 d = float2(EPS, 0.0);
return normalize(float3(
sd(p + d.xyy).value - sd(p - d.xyy).value,
sd(p + d.yxy).value - sd(p - d.yxy).value,
sd(p + d.yyx).value - sd(p - d.yyx).value
));
}
float4 materialColor(int obj_id) {
<COLOR_BLOCK>
return float4(1.0);
}
float4 color(float3 p, int obj_id) {
float3 normal = nrm(p);
float3 light_dir = normalize(float3(0.5, 0.8, -0.6));
float light = 0.2 + 0.8 * max(dot(normal, light_dir), 0.0);
float4 base = materialColor(obj_id);
return float4(base.rgb * light, base.a);
}
float4 march(float3 p, float3 r) {
sdQuery qry;
float4 col = float4(0.0);
for(int i = 0; i < MAX_ITER; ++i) {
qry = sd(p);
if(qry.value < EPS){
col = color(p, qry.obj_id);
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 world_up = abs(c_z.y) > 0.999 ? float3(0., 0., 1.) : float3(0., 1., 0.);
float3 c_x = normalize(cross(c_z, world_up));
float3 c_y = normalize(cross(c_x, c_z));
float3x3 view = float3x3(c_x, c_y, c_z);
float3 r = normalize(float3(uv, <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,69 @@
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}))"
"""
TODO: 除零出现 nan 情况的单独处理
"""
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, x))
elif len(args) == 4:
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]