Фракталы и шейдеры. OpenGL 3.2+

Тема в разделе "WASM.OpenGL", создана пользователем ml64, 18 окт 2026 в 02:09.

  1. ml64

    ml64 Active Member

    Публикаций:
    0
    Регистрация:
    29 окт 2017
    Сообщения:
    146
    Вдохновлён примерами из книги Рэнди Дж. Роста "Трехмерная графика и язык программирования шейдеров".

    На движке RBMK64 построена плоскость, на плоскость натянута текстура.
    Всё просто:
    Код (C):
    1. #version 330 core
    2.  
    3. layout (location = 0) in vec3 aPos;
    4. layout (location = 1) in vec3 aNormal;   // unused by the fractal, kept for VAO compatibility
    5. layout (location = 2) in vec3 aColor;    // tint for the fractal
    6.  
    7. uniform mat4 uModel;
    8. uniform mat4 uView;
    9. uniform mat4 uProjection;
    10.  
    11. out vec2 vFractalUV;
    12. out vec3 vColor;
    13.  
    14. void main()
    15. {
    16.     vFractalUV = aPos.xy;
    17.     vColor    = aColor;
    18.     gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
    19. }

    Код (C):
    1. #version 330 core
    2.  
    3. in vec2 vFractalUV;
    4. in vec3 vColor;
    5.  
    6. uniform vec2  uCenter;
    7. uniform float uZoom;
    8. uniform float uAspect;
    9.  
    10. out vec4 FragColor;
    11.  
    12. // IQ cosine palette — bright and continuous
    13. vec3 palette(float t)
    14. {
    15.     return 0.5 + 0.5 * cos(6.28318 * (vec3(1.0, 1.0, 1.0) * t +
    16.                                       vec3(0.0, 0.33, 0.67)));
    17. }
    18.  
    19. void main()
    20. {
    21.     vec2 p = vec2(vFractalUV.x * uAspect, vFractalUV.y) * uZoom + uCenter;
    22.  
    23.     float logScale = log2(max(1.0 / max(uZoom, 1e-6), 1.0));
    24.     float posTerm  = 0.5 * length(p - vec2(-0.75, 0.0));
    25.     int   maxIter  = int(64.0 + 32.0 * clamp(logScale + posTerm, 0.0, 12.0));
    26.     maxIter = min(maxIter, 512);
    27.  
    28.     vec2 z = vec2(0.0);
    29.     int i = 0;
    30.     for (; i < maxIter; ++i)
    31.     {
    32.         float zx2 = z.x * z.x;
    33.         float zy2 = z.y * z.y;
    34.         if (zx2 + zy2 > 4.0) break;
    35.         z = vec2(zx2 - zy2 + p.x, 2.0 * z.x * z.y + p.y);
    36.     }
    37.  
    38.     vec3 color;
    39.     if (i == maxIter)
    40.     {
    41.         // Interior: very dark purple instead of pure black
    42.         color = vec3(0.05, 0.0, 0.10);
    43.     }
    44.     else
    45.     {
    46.         float t = float(i) / float(maxIter);
    47.         t = pow(t, 0.4);             // log-ish spread: more range spent on small t
    48.         color = palette(t);
    49.     }
    50.  
    51.     FragColor = vec4(color * vColor, 1.0);
    52. }

    Все преимущества 3D-движка сохраняются (движение, вращение)

    Единственное, чего пока не сделал: фрактал - это самоподобие, для фрактала очень важен зум.
    нужен динамический пересчёт перспективы (near-far), ну и итеративный пересчёт геометрии, конечно.

    ...и для фракталов размер float - уже маловат. Но переписать движок ради фрактала я пока не готов.

    Mandelbrot.jpg
     

    Вложения:

    • Mandelbrot.7z
      Размер файла:
      33,4 КБ
      Просмотров:
      28
    Последнее редактирование: 18 окт 2026 в 03:48
    Mikl___ нравится это.
  2. Mikl___

    Mikl___ Супермодератор Команда форума

    Публикаций:
    14
    Регистрация:
    25 июн 2008
    Сообщения:
    4.297
  3. Ahimov

    Ahimov Active Member

    Публикаций:
    0
    Регистрация:
    14 окт 2024
    Сообщения:
    919
    ml64,

    Похоже?

    _outfr.png

    z.ai собрал по исходному проекту, дипси упростил.
     

    Вложения:

    • _outfr.mp4
      Размер файла:
      12,9 МБ
      Просмотров:
      28
    • _fr.txt
      Размер файла:
      6,3 КБ
      Просмотров:
      29
    ml64 нравится это.
  4. Dmitry_Milk

    Dmitry_Milk Member

    Публикаций:
    0
    Регистрация:
    20 ноя 2007
    Сообщения:
    604
    Нафига? Мандельброт как раз тем и хорош, что у него каждая точка считается независимо. Он прям как будто предназначен, чтоб считать шейдером параллельно.
     
    ml64 нравится это.
  5. ml64

    ml64 Active Member

    Публикаций:
    0
    Регистрация:
    29 окт 2017
    Сообщения:
    146
    Да, LLM добавили итеративность. Я у себя тоже обязательно сделаю.

    Знаю, знаю. Но мне уже очень хотелось увидеть мой первый фрактал, построенный шейдером, поэтому сделал, как быстрее.
    Что дальше:
    1) я убираю из движка всё, что касается 3D-матриц;
    2) меняю размер на double;
    3) вставляю 2D-геометрию (full-screen quad или surface в терминологии Vulkan и Direct3D);
    4) получаю всё равно текстуру, натянутую на полигон (ну так это работает);
    5) вместо движения камеры вперёд-назад делаю зум с расчётом нужной глубины итераций. Шаг зума - не линейный, как при движении камеры, а логарифмический, но это не точно.
     
    Последнее редактирование: 18 окт 2026 в 14:58
    Mikl___ нравится это.
  6. GRAFik

    GRAFik Active Member

    Публикаций:
    0
    Регистрация:
    14 мар 2020
    Сообщения:
    572
    Предлагаю сброситься, выкупить этот сайт и оживить его. Всё равно хозяева его забросили, может отдадут задешево, а то и вообще, бесплатно подарят. :) :offtopic:
    https://www.demoscene.ru/forum/
     
    Application нравится это.
  7. Dmitry_Milk

    Dmitry_Milk Member

    Публикаций:
    0
    Регистрация:
    20 ноя 2007
    Сообщения:
    604
    Действительно мертвый, в "музыке" ни одной моей биперной демки за этот год.

    Правда непонятно, какое это имеет отношение к названию этой темы?

    Насчет хозяев - с этого года делегирование доменов в зоне ру возможно только с подтверждением через Госуслуги.
     
  8. GRAFik

    GRAFik Active Member

    Публикаций:
    0
    Регистрация:
    14 мар 2020
    Сообщения:
    572
    Ну как же, а фракталы, а шейдеры. Мне раньше иногда казалось, что Битл кроме терминов: ассемблер и шейдеры - больше никаких терминов и не знает. Ну и термин фракталы, он иногда в своих комментариях использовал. :)
     
  9. ml64

    ml64 Active Member

    Публикаций:
    0
    Регистрация:
    29 окт 2017
    Сообщения:
    146

    Гуляли когда-нибудь внутри Губки Боба Менгера?


    Осторожно! Это восьмая итерация.

    upload_2026-10-19_22-39-51.png
    --- Сообщение объединено, 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 года.

    Menger614FPS.jpg
     

    Вложения:

    • Menger_12.zip
      Размер файла:
      61,1 КБ
      Просмотров:
      11
    GRAFik и aa_dav нравится это.
  10. aa_dav

    aa_dav Active Member

    Публикаций:
    0
    Регистрация:
    24 дек 2008
    Сообщения:
    618
    Не могу не вспомнить тут такого эпохального исследователя фратальных миров тут как San Base и его последний продукт основанный на наработках уже более чем десятилетней давности: https://store.steampowered.com/app/4081860/MandelCraft/

    Пример:
    [​IMG]
    [​IMG]

    Насколько я знаю по его темам на gamedev.ru базис этих штук это мандельбокс с очень сильными годами наработанными доделками - алгоритм объёмной отрисовки где лучик тыкается по схеме одновременно похожей и на мандельброта и на губку менгера:

    [​IMG]
    Внося в эту формулу возмущения и возможно скрещивая с другим эталоном - Мандельбульбой - он получает просто нечто невообразимое.
     
    ml64 нравится это.
  11. GRAFik

    GRAFik Active Member

    Публикаций:
    0
    Регистрация:
    14 мар 2020
    Сообщения:
    572
    Не знаю плохо это или хорошо, но я добавил в код ml64 немного анимации. Мне с анимацией тоже нравится. :)
     

    Вложения:

    ml64 и aa_dav нравится это.
  12. ml64

    ml64 Active Member

    Публикаций:
    0
    Регистрация:
    29 окт 2017
    Сообщения:
    146
    Он выбрал DirectX 12. Учтём

    Отлично! Если развернуть на полный экран, зайти внутрь фрактала и запустить анимацию, то мозг выносится гарантированно.
     
    Последнее редактирование: 20 окт 2026 в 11:22
  13. Application

    Application Moderator Команда форума

    Публикаций:
    1
    Регистрация:
    8 дек 2007
    Сообщения:
    1.076
    Может у кого-то получится более качественный пример. Клавиша N - новая генерация.
    Код (Python):
    1. """
    2. Mandelbox 3D raymarcher с вариативной генерацией — pygame + numpy.
    3.  
    4. Управление:
    5.  Мышь (ЛКМ + движение)  — вращение камеры
    6.  WASD                   — движение вперёд/назад/влево/вправо
    7.  Q / E                  — вниз / вверх
    8.  N                      — новый случайный пресет
    9.  M                      — плавный морфинг к следующему случайному пресету
    10.  + / -                  — scale
    11.  , / .                  — min_radius
    12.  ; / '                  — fixed_radius
    13.  - / =                  — fold_limit (перекрывается с +/- в 3D; работает в 2D)
    14.  [ / ]                  — итерации
    15.  1                      — 3D режим (ray-marching)
    16.  2                      — 2D срез z=0 (быстрый, для отладки)
    17.  H                      — HUD вкл/выкл
    18.  R                      — сброс камеры и параметров
    19.  ESC                    — выход
    20. """
    21.  
    22. import sys
    23. import math
    24. import time
    25. import random
    26. import numpy as np
    27. import pygame
    28.  
    29. # =====================================================================
    30. # СЕКЦИЯ 1. МАТЕМАТИКА — не знает про pygame
    31. # =====================================================================
    32.  
    33. class MandelboxParams:
    34.     """Набор параметров формы. Все поля — float, кроме iterations."""
    35.     def __init__(self, scale=-1.7, min_radius=0.5, fixed_radius=1.0,
    36.                  fold_limit=1.0, iterations=10):
    37.         self.scale = float(scale)
    38.         self.min_radius = float(min_radius)
    39.         self.fixed_radius = float(fixed_radius)
    40.         self.fold_limit = float(fold_limit)
    41.         self.iterations = int(iterations)
    42.  
    43.     def copy(self):
    44.         return MandelboxParams(self.scale, self.min_radius,
    45.                                self.fixed_radius, self.fold_limit,
    46.                                self.iterations)
    47.  
    48.     def as_tuple(self):
    49.         return (self.scale, self.min_radius, self.fixed_radius,
    50.                 self.fold_limit, self.iterations)
    51.  
    52.     def __repr__(self):
    53.         return (f"scale={self.scale:.2f} minR={self.min_radius:.2f} "
    54.                 f"fixR={self.fixed_radius:.2f} fold={self.fold_limit:.2f} "
    55.                 f"iter={self.iterations}")
    56.  
    57.  
    58. def random_preset(rng=None):
    59.     """Случайный «хороший» пресет. Диапазоны подобраны, чтобы формы
    60.    были интересными и не вырождались в шум/пустоту."""
    61.     r = rng or random
    62.     return MandelboxParams(
    63.         scale=r.choice([-1.5, -1.7, -2.0, -2.2, -2.5, -2.8, -3.0]),
    64.         min_radius=r.uniform(0.25, 0.75),
    65.         fixed_radius=r.uniform(0.8, 1.5),
    66.         fold_limit=r.uniform(0.8, 1.5),
    67.         iterations=r.randint(7, 13),
    68.     )
    69.  
    70.  
    71. def lerp_params(a, b, t):
    72.     """Линейная интерполяция параметров для морфинга."""
    73.     return MandelboxParams(
    74.         scale=a.scale + (b.scale - a.scale) * t,
    75.         min_radius=a.min_radius + (b.min_radius - a.min_radius) * t,
    76.         fixed_radius=a.fixed_radius + (b.fixed_radius - a.fixed_radius) * t,
    77.         fold_limit=a.fold_limit + (b.fold_limit - a.fold_limit) * t,
    78.         iterations=int(round(a.iterations + (b.iterations - a.iterations) * t)),
    79.     )
    80.  
    81.  
    82. def sdf_mandelbox(P, params):
    83.     """Distance estimator. P: (N,3) → (N,)."""
    84.     z = P.astype(np.float32, copy=True)
    85.     dr = np.ones(len(P), dtype=np.float32)
    86.  
    87.     s = np.float32(params.scale)
    88.     min_r2 = np.float32(max(params.min_radius ** 2, 1e-6))
    89.     fixed_r2 = np.float32(max(params.fixed_radius ** 2, 1e-6))
    90.     fl = np.float32(params.fold_limit)
    91.     abs_s = np.float32(abs(params.scale))
    92.  
    93.     for _ in range(params.iterations):
    94.         z = np.clip(z, -fl, fl) * 2.0 - z
    95.         r2 = np.einsum('ij,ij->i', z, z)
    96.         sf = np.where(r2 < min_r2,
    97.                       fixed_r2 / min_r2,
    98.                       np.where(r2 < fixed_r2,
    99.                                fixed_r2 / np.maximum(r2, 1e-12),
    100.                                1.0)).astype(np.float32)
    101.         z *= sf[:, None]
    102.         dr *= np.abs(sf)
    103.         z = z * s + P
    104.         dr = dr * abs_s + 1.0
    105.     return (np.linalg.norm(z, axis=1) / np.maximum(dr, 1e-12)).astype(np.float32)
    106.  
    107.  
    108. def estimate_normal(P, params, eps=1e-3):
    109.     n = np.zeros_like(P)
    110.     for i in range(3):
    111.         d = np.zeros(3, dtype=np.float32); d[i] = eps
    112.         n[:, i] = sdf_mandelbox(P + d, params) - sdf_mandelbox(P - d, params)
    113.     l = np.linalg.norm(n, axis=1, keepdims=True)
    114.     return (n / np.maximum(l, 1e-12)).astype(np.float32)
    115.  
    116.  
    117. class Camera:
    118.     def __init__(self):
    119.         self.pos = np.array([2.5, 2.0, 2.5], dtype=np.float32)
    120.         self.yaw = -2.3
    121.         self.pitch = -0.4
    122.         self.fov = math.radians(60.0)
    123.  
    124.     def forward(self):
    125.         cy, sy = math.cos(self.yaw), math.sin(self.yaw)
    126.         cp, sp = math.cos(self.pitch), math.sin(self.pitch)
    127.         return np.array([cp * cy, cp * sy, sp], dtype=np.float32)
    128.  
    129.     def right_up(self):
    130.         f = self.forward()
    131.         world_up = np.array([0, 0, 1], dtype=np.float32)
    132.         r = np.cross(f, world_up)
    133.         if np.linalg.norm(r) < 1e-6:
    134.             r = np.array([1, 0, 0], dtype=np.float32)
    135.         r /= np.linalg.norm(r)
    136.         return r, np.cross(r, f)
    137.  
    138.  
    139. def generate_rays(cam, width, height):
    140.     f = cam.forward()
    141.     r, u = cam.right_up()
    142.     aspect = width / height
    143.     half_h = math.tan(cam.fov * 0.5)
    144.     half_w = half_h * aspect
    145.     xs = np.linspace(-half_w, half_w, width, dtype=np.float32)
    146.     ys = np.linspace(half_h, -half_h, height, dtype=np.float32)
    147.     X, Y = np.meshgrid(xs, ys)
    148.     dirs = (f[None, None, :]
    149.             + X[..., None] * r[None, None, :]
    150.             + Y[..., None] * u[None, None, :])
    151.     dirs /= np.linalg.norm(dirs, axis=2, keepdims=True)
    152.     return dirs.reshape(-1, 3)
    153.  
    154.  
    155. def shade(hit, t, dirs, normals, max_dist, steps_taken, palette_shift=0.0):
    156.     """Простое освещение + палитра, сдвигаемая с пресетом."""
    157.     N = len(hit)
    158.     rgb = np.zeros((N, 3), dtype=np.float32)
    159.     bg = np.clip(0.15 + 0.25 * (1.0 - np.abs(dirs[:, 2])), 0, 1)
    160.     rgb[:] = np.stack([bg * 0.4, bg * 0.5, bg * 0.8], axis=1)
    161.  
    162.     if hit.any():
    163.         idx = np.nonzero(hit)[0]
    164.         n = normals[idx]
    165.         light = np.array([0.6, 0.7, 0.8], dtype=np.float32)
    166.         light /= np.linalg.norm(light)
    167.         diff = np.clip(np.einsum('ij,j->i', n, light), 0, 1)
    168.         ao = np.clip(1.0 - steps_taken[idx].astype(np.float32) / 96.0, 0.4, 1.0)
    169.         shade_amt = (0.15 + 0.85 * diff) * ao
    170.  
    171.         # палитра, сдвигаемая через hue-rotate (грубая, но работает)
    172.         base = np.stack([
    173.             0.85 + 0.15 * n[:, 0],
    174.             0.55 + 0.30 * n[:, 1],
    175.             0.30 + 0.50 * n[:, 2],
    176.         ], axis=1)
    177.         if palette_shift:
    178.             c, s_ = math.cos(palette_shift), math.sin(palette_shift)
    179.             # лёгкий поворот R-канала относительно G
    180.             base[:, 0], base[:, 1] = (base[:, 0] * c - base[:, 1] * s_,
    181.                                       base[:, 0] * s_ + base[:, 1] * c)
    182.  
    183.         fog = np.clip(1.0 - t[idx] / max_dist, 0.1, 1.0)
    184.         col = base * shade_amt[:, None] * fog[:, None] \
    185.               + bg[idx, None] * (1 - fog[:, None])
    186.         rgb[idx] = np.clip(col, 0, 1)
    187.     return (rgb * 255).astype(np.uint8)
    188.  
    189.  
    190. def render_2d_slice(params, width, height, view_scale=2.5):
    191.     xs = np.linspace(-view_scale, view_scale, width, dtype=np.float32)
    192.     ys = np.linspace(view_scale, -view_scale, height, dtype=np.float32)
    193.     X, Y = np.meshgrid(xs, ys)
    194.     P = np.stack([X, Y, np.zeros_like(X)], axis=-1).reshape(-1, 3)
    195.     d = sdf_mandelbox(P, params).reshape(height, width)
    196.     img = np.clip(1.0 - np.abs(d) * 3.0, 0, 1)
    197.     return (img * 255).astype(np.uint8)
    198.  
    199.  
    200. # =====================================================================
    201. # СЕКЦИЯ 2. ВИЗУАЛИЗАЦИЯ
    202. # =====================================================================
    203.  
    204. def numpy_to_surface(gray_or_rgb, width, height):
    205.     if gray_or_rgb.ndim == 2:
    206.         arr = np.repeat(gray_or_rgb[:, :, None], 3, axis=2)
    207.     else:
    208.         arr = gray_or_rgb
    209.     surf = pygame.Surface((width, height))
    210.     pygame.surfarray.blit_array(surf, arr.transpose(1, 0, 2))
    211.     return surf
    212.  
    213.  
    214. class CameraController:
    215.     MOVE_SPEED = 0.08
    216.     ROT_SPEED = 0.005
    217.  
    218.     def handle(self, events, keys, cam, params, app_state):
    219.         if pygame.mouse.get_pressed()[0]:
    220.             dx, dy = pygame.mouse.get_rel()
    221.             cam.yaw += dx * self.ROT_SPEED
    222.             cam.pitch -= dy * self.ROT_SPEED
    223.             cam.pitch = max(-1.5, min(1.5, cam.pitch))
    224.  
    225.         f = cam.forward()
    226.         r, _ = cam.right_up()
    227.         if keys[pygame.K_w]: cam.pos += f * self.MOVE_SPEED
    228.         if keys[pygame.K_s]: cam.pos -= f * self.MOVE_SPEED
    229.         if keys[pygame.K_a]: cam.pos -= r * self.MOVE_SPEED
    230.         if keys[pygame.K_d]: cam.pos += r * self.MOVE_SPEED
    231.         if keys[pygame.K_e]: cam.pos[2] += self.MOVE_SPEED
    232.         if keys[pygame.K_q]: cam.pos[2] -= self.MOVE_SPEED
    233.  
    234.         for ev in events:
    235.             if ev.type != pygame.KEYDOWN:
    236.                 continue
    237.             k = ev.key
    238.             if k in (pygame.K_PLUS, pygame.K_EQUALS):
    239.                 params.scale = min(-1.05, params.scale + 0.05)
    240.             elif k == pygame.K_MINUS:
    241.                 params.scale = max(-3.5, params.scale - 0.05)
    242.             elif k == pygame.K_COMMA:
    243.                 params.min_radius = max(0.1, params.min_radius - 0.02)
    244.             elif k == pygame.K_PERIOD:
    245.                 params.min_radius = min(1.5, params.min_radius + 0.02)
    246.             elif k == pygame.K_SEMICOLON:
    247.                 params.fixed_radius = max(0.3, params.fixed_radius - 0.02)
    248.             elif k == pygame.K_QUOTE:
    249.                 params.fixed_radius = min(2.5, params.fixed_radius + 0.02)
    250.             elif k == pygame.K_LEFTBRACKET:
    251.                 params.iterations = max(3, params.iterations - 1)
    252.             elif k == pygame.K_RIGHTBRACKET:
    253.                 params.iterations = min(20, params.iterations + 1)
    254.             elif k == pygame.K_n:
    255.                 app_state['target'] = random_preset()
    256.                 app_state['morph'] = None
    257.                 app_state['apply_now'] = True
    258.             elif k == pygame.K_m:
    259.                 app_state['target'] = random_preset()
    260.                 app_state['morph'] = {
    261.                     'from': params.copy(),
    262.                     'to': app_state['target'],
    263.                     'start': time.time(),
    264.                     'dur': 2.0,
    265.                 }
    266.             elif k == pygame.K_h:
    267.                 app_state['hud'] = not app_state['hud']
    268.             elif k == pygame.K_r:
    269.                 cam.__init__()
    270.                 params.__init__()
    271.                 app_state['morph'] = None
    272.  
    273.  
    274. # =====================================================================
    275. # СЕКЦИЯ 3. ОРКЕСТРАТОР
    276. # =====================================================================
    277.  
    278. def main():
    279.     WIDTH, HEIGHT = 320, 240
    280.     MODE_2D_SCALE = 320
    281.  
    282.     pygame.init()
    283.     screen = pygame.display.set_mode((WIDTH, HEIGHT))
    284.     pygame.display.set_caption("Mandelbox — вариативная генерация")
    285.     clock = pygame.time.Clock()
    286.     font = pygame.font.SysFont("consolas", 12)
    287.  
    288.     cam = Camera()
    289.     params = MandelboxParams()
    290.     controller = CameraController()
    291.  
    292.     app_state = {
    293.         'hud': True,
    294.         'target': None,
    295.         'morph': None,
    296.         'apply_now': False,
    297.         'palette_shift': 0.0,
    298.     }
    299.  
    300.     mode_3d = True
    301.     dirty = True
    302.     last_stats = ""
    303.  
    304.     pygame.mouse.get_rel()
    305.  
    306.     while True:
    307.         now = time.time()
    308.         events = pygame.event.get()
    309.         for ev in events:
    310.             if ev.type == pygame.QUIT:
    311.                 pygame.quit(); return
    312.             if ev.type == pygame.KEYDOWN:
    313.                 if ev.key == pygame.K_ESCAPE:
    314.                     pygame.quit(); return
    315.                 if ev.key == pygame.K_1:
    316.                     mode_3d = True; dirty = True
    317.                 if ev.key == pygame.K_2:
    318.                     mode_3d = False; dirty = True
    319.  
    320.         keys = pygame.key.get_pressed()
    321.         prev = params.as_tuple()
    322.         prev_cam = (cam.pos.copy(), cam.yaw, cam.pitch)
    323.  
    324.         controller.handle(events, keys, cam, params, app_state)
    325.  
    326.         # немедленное применение пресета (клавиша N)
    327.         if app_state['apply_now']:
    328.             params.__dict__.update(app_state['target'].__dict__)
    329.             app_state['apply_now'] = False
    330.             app_state['palette_shift'] = random.uniform(0, 2 * math.pi)
    331.             dirty = True
    332.  
    333.         # морфинг
    334.         if app_state['morph'] is not None:
    335.             m = app_state['morph']
    336.             t = (now - m['start']) / m['dur']
    337.             if t >= 1.0:
    338.                 params.__dict__.update(m['to'].__dict__)
    339.                 app_state['morph'] = None
    340.                 app_state['palette_shift'] = random.uniform(0, 2 * math.pi)
    341.             else:
    342.                 # сглаживание (ease-in-out)
    343.                 te = t * t * (3 - 2 * t)
    344.                 interp = lerp_params(m['from'], m['to'], te)
    345.                 params.__dict__.update(interp.__dict__)
    346.             dirty = True
    347.  
    348.         if prev != params.as_tuple() or \
    349.            not (np.array_equal(prev_cam[0], cam.pos)
    350.                 and prev_cam[1] == cam.yaw and prev_cam[2] == cam.pitch):
    351.             dirty = True
    352.  
    353.         if dirty:
    354.             t0 = time.time()
    355.             if mode_3d:
    356.                 dirs = generate_rays(cam, WIDTH, HEIGHT)
    357.                 origins = np.broadcast_to(cam.pos, dirs.shape).copy()
    358.  
    359.                 max_steps, eps, max_dist = 96, 1e-3, 15.0
    360.                 t = np.zeros(len(dirs), dtype=np.float32)
    361.                 alive = np.ones(len(dirs), dtype=bool)
    362.                 hit = np.zeros(len(dirs), dtype=bool)
    363.                 steps_taken = np.zeros(len(dirs), dtype=np.int32)
    364.  
    365.                 for _ in range(max_steps):
    366.                     if not alive.any():
    367.                         break
    368.                     idx = np.nonzero(alive)[0]
    369.                     P = origins[idx] + dirs[idx] * t[idx, None]
    370.                     d = sdf_mandelbox(P, params)
    371.                     t[idx] += d * 0.85
    372.                     steps_taken[idx] += 1
    373.                     newly_hit = d < eps
    374.                     hit[idx[newly_hit]] = True
    375.                     alive[idx[newly_hit | (t[idx] > max_dist)]] = False
    376.  
    377.                 P_final = origins + dirs * t[:, None]
    378.                 normals = estimate_normal(P_final, params, eps=1e-3)
    379.                 rgb = shade(hit, t, dirs, normals, max_dist, steps_taken,
    380.                             app_state['palette_shift'])
    381.                 surf = numpy_to_surface(rgb.reshape(HEIGHT, WIDTH, 3),
    382.                                         WIDTH, HEIGHT)
    383.             else:
    384.                 gray = render_2d_slice(params, MODE_2D_SCALE, MODE_2D_SCALE)
    385.                 surf = numpy_to_surface(gray, MODE_2D_SCALE, MODE_2D_SCALE)
    386.                 surf = pygame.transform.scale(surf, (WIDTH, HEIGHT))
    387.  
    388.             last_stats = (f"t={time.time()-t0:.2f}s  {params}  "
    389.                           f"{'3D' if mode_3d else '2D'}")
    390.             dirty = False
    391.  
    392.             screen.blit(surf, (0, 0))
    393.             if app_state['hud']:
    394.                 hud = font.render(last_stats, True, (255, 255, 100))
    395.                 screen.blit(hud, (4, 4))
    396.                 hint = font.render("N=new M=morph WASD=LMB=look 1/2=mode H=hud",
    397.                                    True, (180, 220, 255))
    398.                 screen.blit(hint, (4, HEIGHT - 16))
    399.             pygame.display.flip()
    400.  
    401.         clock.tick(60)
    402.  
    403.  
    404. if __name__ == "__main__":
    405.     main()