Вдохновлён примерами из книги Рэнди Дж. Роста "Трехмерная графика и язык программирования шейдеров". На движке RBMK64 построена плоскость, на плоскость натянута текстура. Всё просто: Спойлер: Вершинный шейдер Код (C): #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; // unused by the fractal, kept for VAO compatibility layout (location = 2) in vec3 aColor; // tint for the fractal uniform mat4 uModel; uniform mat4 uView; uniform mat4 uProjection; out vec2 vFractalUV; out vec3 vColor; void main() { vFractalUV = aPos.xy; vColor = aColor; gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); } Спойлер: Фрагментный шейдер Код (C): #version 330 core in vec2 vFractalUV; in vec3 vColor; uniform vec2 uCenter; uniform float uZoom; uniform float uAspect; out vec4 FragColor; // IQ cosine palette — bright and continuous vec3 palette(float t) { return 0.5 + 0.5 * cos(6.28318 * (vec3(1.0, 1.0, 1.0) * t + vec3(0.0, 0.33, 0.67))); } void main() { vec2 p = vec2(vFractalUV.x * uAspect, vFractalUV.y) * uZoom + uCenter; float logScale = log2(max(1.0 / max(uZoom, 1e-6), 1.0)); float posTerm = 0.5 * length(p - vec2(-0.75, 0.0)); int maxIter = int(64.0 + 32.0 * clamp(logScale + posTerm, 0.0, 12.0)); maxIter = min(maxIter, 512); vec2 z = vec2(0.0); int i = 0; for (; i < maxIter; ++i) { float zx2 = z.x * z.x; float zy2 = z.y * z.y; if (zx2 + zy2 > 4.0) break; z = vec2(zx2 - zy2 + p.x, 2.0 * z.x * z.y + p.y); } vec3 color; if (i == maxIter) { // Interior: very dark purple instead of pure black color = vec3(0.05, 0.0, 0.10); } else { float t = float(i) / float(maxIter); t = pow(t, 0.4); // log-ish spread: more range spent on small t color = palette(t); } FragColor = vec4(color * vColor, 1.0); } Все преимущества 3D-движка сохраняются (движение, вращение) Единственное, чего пока не сделал: фрактал - это самоподобие, для фрактала очень важен зум. нужен динамический пересчёт перспективы (near-far), ну и итеративный пересчёт геометрии, конечно. ...и для фракталов размер float - уже маловат. Но переписать движок ради фрактала я пока не готов.
Нафига? Мандельброт как раз тем и хорош, что у него каждая точка считается независимо. Он прям как будто предназначен, чтоб считать шейдером параллельно.
Да, LLM добавили итеративность. Я у себя тоже обязательно сделаю. Знаю, знаю. Но мне уже очень хотелось увидеть мой первый фрактал, построенный шейдером, поэтому сделал, как быстрее. Что дальше: 1) я убираю из движка всё, что касается 3D-матриц; 2) меняю размер на double; 3) вставляю 2D-геометрию (full-screen quad или surface в терминологии Vulkan и Direct3D); 4) получаю всё равно текстуру, натянутую на полигон (ну так это работает); 5) вместо движения камеры вперёд-назад делаю зум с расчётом нужной глубины итераций. Шаг зума - не линейный, как при движении камеры, а логарифмический, но это не точно.
Предлагаю сброситься, выкупить этот сайт и оживить его. Всё равно хозяева его забросили, может отдадут задешево, а то и вообще, бесплатно подарят. https://www.demoscene.ru/forum/
Действительно мертвый, в "музыке" ни одной моей биперной демки за этот год. Правда непонятно, какое это имеет отношение к названию этой темы? Насчет хозяев - с этого года делегирование доменов в зоне ру возможно только с подтверждением через Госуслуги.
Ну как же, а фракталы, а шейдеры. Мне раньше иногда казалось, что Битл кроме терминов: ассемблер и шейдеры - больше никаких терминов и не знает. Ну и термин фракталы, он иногда в своих комментариях использовал.
Гуляли когда-нибудь внутри Губки Боба Менгера? Осторожно! Это восьмая итерация. --- Сообщение объединено, 19 окт 2026 в 22:52 --- Похоже, что пора переходить на double. Для фракталов (особенно 3D+) float не хватает. Придётся апнуть OpenGL до 4.0 (это 2010 год, период соответствует CPU Intel Core 1 поколения). Или остаёмся в Legacy для совместимости? --- Сообщение объединено, 19 окт 2026 в 23:27 --- Ничего необычного. Просто Губка Менгера с частотой 614 FPS на интегрированном Iris Xe 2023 года.
Не могу не вспомнить тут такого эпохального исследователя фратальных миров тут как San Base и его последний продукт основанный на наработках уже более чем десятилетней давности: https://store.steampowered.com/app/4081860/MandelCraft/ Пример: Насколько я знаю по его темам на gamedev.ru базис этих штук это мандельбокс с очень сильными годами наработанными доделками - алгоритм объёмной отрисовки где лучик тыкается по схеме одновременно похожей и на мандельброта и на губку менгера: Внося в эту формулу возмущения и возможно скрещивая с другим эталоном - Мандельбульбой - он получает просто нечто невообразимое.
Не знаю плохо это или хорошо, но я добавил в код ml64 немного анимации. Мне с анимацией тоже нравится.
Он выбрал DirectX 12. Учтём Отлично! Если развернуть на полный экран, зайти внутрь фрактала и запустить анимацию, то мозг выносится гарантированно.
Может у кого-то получится более качественный пример. Клавиша N - новая генерация. Код (Python): """ Mandelbox 3D raymarcher с вариативной генерацией — pygame + numpy. Управление: Мышь (ЛКМ + движение) — вращение камеры WASD — движение вперёд/назад/влево/вправо Q / E — вниз / вверх N — новый случайный пресет M — плавный морфинг к следующему случайному пресету + / - — scale , / . — min_radius ; / ' — fixed_radius - / = — fold_limit (перекрывается с +/- в 3D; работает в 2D) [ / ] — итерации 1 — 3D режим (ray-marching) 2 — 2D срез z=0 (быстрый, для отладки) H — HUD вкл/выкл R — сброс камеры и параметров ESC — выход """ import sys import math import time import random import numpy as np import pygame # ===================================================================== # СЕКЦИЯ 1. МАТЕМАТИКА — не знает про pygame # ===================================================================== class MandelboxParams: """Набор параметров формы. Все поля — float, кроме iterations.""" def __init__(self, scale=-1.7, min_radius=0.5, fixed_radius=1.0, fold_limit=1.0, iterations=10): self.scale = float(scale) self.min_radius = float(min_radius) self.fixed_radius = float(fixed_radius) self.fold_limit = float(fold_limit) self.iterations = int(iterations) def copy(self): return MandelboxParams(self.scale, self.min_radius, self.fixed_radius, self.fold_limit, self.iterations) def as_tuple(self): return (self.scale, self.min_radius, self.fixed_radius, self.fold_limit, self.iterations) def __repr__(self): return (f"scale={self.scale:.2f} minR={self.min_radius:.2f} " f"fixR={self.fixed_radius:.2f} fold={self.fold_limit:.2f} " f"iter={self.iterations}") def random_preset(rng=None): """Случайный «хороший» пресет. Диапазоны подобраны, чтобы формы были интересными и не вырождались в шум/пустоту.""" r = rng or random return MandelboxParams( scale=r.choice([-1.5, -1.7, -2.0, -2.2, -2.5, -2.8, -3.0]), min_radius=r.uniform(0.25, 0.75), fixed_radius=r.uniform(0.8, 1.5), fold_limit=r.uniform(0.8, 1.5), iterations=r.randint(7, 13), ) def lerp_params(a, b, t): """Линейная интерполяция параметров для морфинга.""" return MandelboxParams( scale=a.scale + (b.scale - a.scale) * t, min_radius=a.min_radius + (b.min_radius - a.min_radius) * t, fixed_radius=a.fixed_radius + (b.fixed_radius - a.fixed_radius) * t, fold_limit=a.fold_limit + (b.fold_limit - a.fold_limit) * t, iterations=int(round(a.iterations + (b.iterations - a.iterations) * t)), ) def sdf_mandelbox(P, params): """Distance estimator. P: (N,3) → (N,).""" z = P.astype(np.float32, copy=True) dr = np.ones(len(P), dtype=np.float32) s = np.float32(params.scale) min_r2 = np.float32(max(params.min_radius ** 2, 1e-6)) fixed_r2 = np.float32(max(params.fixed_radius ** 2, 1e-6)) fl = np.float32(params.fold_limit) abs_s = np.float32(abs(params.scale)) for _ in range(params.iterations): z = np.clip(z, -fl, fl) * 2.0 - z r2 = np.einsum('ij,ij->i', z, z) sf = np.where(r2 < min_r2, fixed_r2 / min_r2, np.where(r2 < fixed_r2, fixed_r2 / np.maximum(r2, 1e-12), 1.0)).astype(np.float32) z *= sf[:, None] dr *= np.abs(sf) z = z * s + P dr = dr * abs_s + 1.0 return (np.linalg.norm(z, axis=1) / np.maximum(dr, 1e-12)).astype(np.float32) def estimate_normal(P, params, eps=1e-3): n = np.zeros_like(P) for i in range(3): d = np.zeros(3, dtype=np.float32); d[i] = eps n[:, i] = sdf_mandelbox(P + d, params) - sdf_mandelbox(P - d, params) l = np.linalg.norm(n, axis=1, keepdims=True) return (n / np.maximum(l, 1e-12)).astype(np.float32) class Camera: def __init__(self): self.pos = np.array([2.5, 2.0, 2.5], dtype=np.float32) self.yaw = -2.3 self.pitch = -0.4 self.fov = math.radians(60.0) def forward(self): cy, sy = math.cos(self.yaw), math.sin(self.yaw) cp, sp = math.cos(self.pitch), math.sin(self.pitch) return np.array([cp * cy, cp * sy, sp], dtype=np.float32) def right_up(self): f = self.forward() world_up = np.array([0, 0, 1], dtype=np.float32) r = np.cross(f, world_up) if np.linalg.norm(r) < 1e-6: r = np.array([1, 0, 0], dtype=np.float32) r /= np.linalg.norm(r) return r, np.cross(r, f) def generate_rays(cam, width, height): f = cam.forward() r, u = cam.right_up() aspect = width / height half_h = math.tan(cam.fov * 0.5) half_w = half_h * aspect xs = np.linspace(-half_w, half_w, width, dtype=np.float32) ys = np.linspace(half_h, -half_h, height, dtype=np.float32) X, Y = np.meshgrid(xs, ys) dirs = (f[None, None, :] + X[..., None] * r[None, None, :] + Y[..., None] * u[None, None, :]) dirs /= np.linalg.norm(dirs, axis=2, keepdims=True) return dirs.reshape(-1, 3) def shade(hit, t, dirs, normals, max_dist, steps_taken, palette_shift=0.0): """Простое освещение + палитра, сдвигаемая с пресетом.""" N = len(hit) rgb = np.zeros((N, 3), dtype=np.float32) bg = np.clip(0.15 + 0.25 * (1.0 - np.abs(dirs[:, 2])), 0, 1) rgb[:] = np.stack([bg * 0.4, bg * 0.5, bg * 0.8], axis=1) if hit.any(): idx = np.nonzero(hit)[0] n = normals[idx] light = np.array([0.6, 0.7, 0.8], dtype=np.float32) light /= np.linalg.norm(light) diff = np.clip(np.einsum('ij,j->i', n, light), 0, 1) ao = np.clip(1.0 - steps_taken[idx].astype(np.float32) / 96.0, 0.4, 1.0) shade_amt = (0.15 + 0.85 * diff) * ao # палитра, сдвигаемая через hue-rotate (грубая, но работает) base = np.stack([ 0.85 + 0.15 * n[:, 0], 0.55 + 0.30 * n[:, 1], 0.30 + 0.50 * n[:, 2], ], axis=1) if palette_shift: c, s_ = math.cos(palette_shift), math.sin(palette_shift) # лёгкий поворот R-канала относительно G base[:, 0], base[:, 1] = (base[:, 0] * c - base[:, 1] * s_, base[:, 0] * s_ + base[:, 1] * c) fog = np.clip(1.0 - t[idx] / max_dist, 0.1, 1.0) col = base * shade_amt[:, None] * fog[:, None] \ + bg[idx, None] * (1 - fog[:, None]) rgb[idx] = np.clip(col, 0, 1) return (rgb * 255).astype(np.uint8) def render_2d_slice(params, width, height, view_scale=2.5): xs = np.linspace(-view_scale, view_scale, width, dtype=np.float32) ys = np.linspace(view_scale, -view_scale, height, dtype=np.float32) X, Y = np.meshgrid(xs, ys) P = np.stack([X, Y, np.zeros_like(X)], axis=-1).reshape(-1, 3) d = sdf_mandelbox(P, params).reshape(height, width) img = np.clip(1.0 - np.abs(d) * 3.0, 0, 1) return (img * 255).astype(np.uint8) # ===================================================================== # СЕКЦИЯ 2. ВИЗУАЛИЗАЦИЯ # ===================================================================== def numpy_to_surface(gray_or_rgb, width, height): if gray_or_rgb.ndim == 2: arr = np.repeat(gray_or_rgb[:, :, None], 3, axis=2) else: arr = gray_or_rgb surf = pygame.Surface((width, height)) pygame.surfarray.blit_array(surf, arr.transpose(1, 0, 2)) return surf class CameraController: MOVE_SPEED = 0.08 ROT_SPEED = 0.005 def handle(self, events, keys, cam, params, app_state): if pygame.mouse.get_pressed()[0]: dx, dy = pygame.mouse.get_rel() cam.yaw += dx * self.ROT_SPEED cam.pitch -= dy * self.ROT_SPEED cam.pitch = max(-1.5, min(1.5, cam.pitch)) f = cam.forward() r, _ = cam.right_up() if keys[pygame.K_w]: cam.pos += f * self.MOVE_SPEED if keys[pygame.K_s]: cam.pos -= f * self.MOVE_SPEED if keys[pygame.K_a]: cam.pos -= r * self.MOVE_SPEED if keys[pygame.K_d]: cam.pos += r * self.MOVE_SPEED if keys[pygame.K_e]: cam.pos[2] += self.MOVE_SPEED if keys[pygame.K_q]: cam.pos[2] -= self.MOVE_SPEED for ev in events: if ev.type != pygame.KEYDOWN: continue k = ev.key if k in (pygame.K_PLUS, pygame.K_EQUALS): params.scale = min(-1.05, params.scale + 0.05) elif k == pygame.K_MINUS: params.scale = max(-3.5, params.scale - 0.05) elif k == pygame.K_COMMA: params.min_radius = max(0.1, params.min_radius - 0.02) elif k == pygame.K_PERIOD: params.min_radius = min(1.5, params.min_radius + 0.02) elif k == pygame.K_SEMICOLON: params.fixed_radius = max(0.3, params.fixed_radius - 0.02) elif k == pygame.K_QUOTE: params.fixed_radius = min(2.5, params.fixed_radius + 0.02) elif k == pygame.K_LEFTBRACKET: params.iterations = max(3, params.iterations - 1) elif k == pygame.K_RIGHTBRACKET: params.iterations = min(20, params.iterations + 1) elif k == pygame.K_n: app_state['target'] = random_preset() app_state['morph'] = None app_state['apply_now'] = True elif k == pygame.K_m: app_state['target'] = random_preset() app_state['morph'] = { 'from': params.copy(), 'to': app_state['target'], 'start': time.time(), 'dur': 2.0, } elif k == pygame.K_h: app_state['hud'] = not app_state['hud'] elif k == pygame.K_r: cam.__init__() params.__init__() app_state['morph'] = None # ===================================================================== # СЕКЦИЯ 3. ОРКЕСТРАТОР # ===================================================================== def main(): WIDTH, HEIGHT = 320, 240 MODE_2D_SCALE = 320 pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("Mandelbox — вариативная генерация") clock = pygame.time.Clock() font = pygame.font.SysFont("consolas", 12) cam = Camera() params = MandelboxParams() controller = CameraController() app_state = { 'hud': True, 'target': None, 'morph': None, 'apply_now': False, 'palette_shift': 0.0, } mode_3d = True dirty = True last_stats = "" pygame.mouse.get_rel() while True: now = time.time() events = pygame.event.get() for ev in events: if ev.type == pygame.QUIT: pygame.quit(); return if ev.type == pygame.KEYDOWN: if ev.key == pygame.K_ESCAPE: pygame.quit(); return if ev.key == pygame.K_1: mode_3d = True; dirty = True if ev.key == pygame.K_2: mode_3d = False; dirty = True keys = pygame.key.get_pressed() prev = params.as_tuple() prev_cam = (cam.pos.copy(), cam.yaw, cam.pitch) controller.handle(events, keys, cam, params, app_state) # немедленное применение пресета (клавиша N) if app_state['apply_now']: params.__dict__.update(app_state['target'].__dict__) app_state['apply_now'] = False app_state['palette_shift'] = random.uniform(0, 2 * math.pi) dirty = True # морфинг if app_state['morph'] is not None: m = app_state['morph'] t = (now - m['start']) / m['dur'] if t >= 1.0: params.__dict__.update(m['to'].__dict__) app_state['morph'] = None app_state['palette_shift'] = random.uniform(0, 2 * math.pi) else: # сглаживание (ease-in-out) te = t * t * (3 - 2 * t) interp = lerp_params(m['from'], m['to'], te) params.__dict__.update(interp.__dict__) dirty = True if prev != params.as_tuple() or \ not (np.array_equal(prev_cam[0], cam.pos) and prev_cam[1] == cam.yaw and prev_cam[2] == cam.pitch): dirty = True if dirty: t0 = time.time() if mode_3d: dirs = generate_rays(cam, WIDTH, HEIGHT) origins = np.broadcast_to(cam.pos, dirs.shape).copy() max_steps, eps, max_dist = 96, 1e-3, 15.0 t = np.zeros(len(dirs), dtype=np.float32) alive = np.ones(len(dirs), dtype=bool) hit = np.zeros(len(dirs), dtype=bool) steps_taken = np.zeros(len(dirs), dtype=np.int32) for _ in range(max_steps): if not alive.any(): break idx = np.nonzero(alive)[0] P = origins[idx] + dirs[idx] * t[idx, None] d = sdf_mandelbox(P, params) t[idx] += d * 0.85 steps_taken[idx] += 1 newly_hit = d < eps hit[idx[newly_hit]] = True alive[idx[newly_hit | (t[idx] > max_dist)]] = False P_final = origins + dirs * t[:, None] normals = estimate_normal(P_final, params, eps=1e-3) rgb = shade(hit, t, dirs, normals, max_dist, steps_taken, app_state['palette_shift']) surf = numpy_to_surface(rgb.reshape(HEIGHT, WIDTH, 3), WIDTH, HEIGHT) else: gray = render_2d_slice(params, MODE_2D_SCALE, MODE_2D_SCALE) surf = numpy_to_surface(gray, MODE_2D_SCALE, MODE_2D_SCALE) surf = pygame.transform.scale(surf, (WIDTH, HEIGHT)) last_stats = (f"t={time.time()-t0:.2f}s {params} " f"{'3D' if mode_3d else '2D'}") dirty = False screen.blit(surf, (0, 0)) if app_state['hud']: hud = font.render(last_stats, True, (255, 255, 100)) screen.blit(hud, (4, 4)) hint = font.render("N=new M=morph WASD=LMB=look 1/2=mode H=hud", True, (180, 220, 255)) screen.blit(hint, (4, HEIGHT - 16)) pygame.display.flip() clock.tick(60) if __name__ == "__main__": main()