Графіка та рендеринг
Квітень 2026 · 18 хв читання · Рендеринг реального часу · Монте-Карло · WebGPU

ReSTIR: просторово-часове ресемплування за важливістю на основі резервуарів

ReSTIR — це алгоритм семплування, що уможливив глобальне освітлення реального часу з тисячами динамічних джерел світла на 60 fps на споживчому обладнанні. Він досягає цього, поєднуючи зважене резервуарне семплування з просторовим та часовим повторним використанням сусідів — фактично дозволяючи кожному пікселю «позичати» хороших кандидатів-семплів у своїх сусідів у просторі та часі, помножуючи ефективну кількість семплів на порядки величини без додаткової вартості променів.

1. Проблема багатьох джерел світла

Рівняння рендерингу вимагає інтегрування вхідної яскравості по всіх напрямках у кожній точці поверхні:

L_o(x, ω_o) = L_e(x, ω_o) + ∫_Ω f_r(x, ω_i, ω_o) · L_i(x, ω_i) · |cos θᵢ| dω_i

У сценах з тисячами динамічних емісійних сіток (площинні джерела світла, емісійні частинки, автомобільні фари, вікна в нічній сцені міста) наївне рівномірне семплування одного джерела світла з усієї множини дає дисперсію, пропорційну N_lights. Для N = 10 000 джерел та 1 семпла на піксель майже кожен семпл потрапить на джерело, що дає мізерний внесок у освітлення поточного пікселя — продукуючи надзвичайно шумні результати.

Ідеалом було б семплювати джерела пропорційно до їхнього внеску p̂(x) = f_r · L_e · |cos θ| / r² — але обчислення цього для кожного джерела × кожного пікселя становить O(N_pixels × N_lights) на кадр, далеко за межами бюджету реального часу.

Попередні роботи: дерева світла та віртуальні точкові джерела

2. Зважене резервуарне семплування

Зважене резервуарне семплування (WRS) (Vitter, 1985) дає один семпл із потоку кандидатів, де кожен елемент xᵢ має вагу wᵢ — без потреби зберігати всі елементи:

Алгоритм WRS (потік x₁, w₁), (x₂, w₂), ..., (xₙ, wₙ): reservoir.y = null reservoir.w_sum = 0 reservoir.M = 0 // кількість побачених кандидатів для кожного (xᵢ, wᵢ): reservoir.w_sum += wᵢ reservoir.M += 1 // Заміни поточний семпл з імовірністю wᵢ / w_sum: if rand() < wᵢ / reservoir.w_sum: reservoir.y = xᵢ // Результат: reservoir.y розподілений пропорційно до ваг wᵢ // Незміщений оцінювач цільового інтеграла ∝ f̂(y) = f(y)/p(y) · w_sum/M

Ключова властивість: після обробки n кандидатів резервуар містить рівно 1 семпл, і P(y = xᵢ) = wᵢ / Σwⱼ. Резервуар можна оновлювати інкрементно — нових кандидатів можна об'єднувати за O(1). Саме це робить ReSTIR дружнім до GPU: кожен піксель підтримує один резервуар у текстурі.

Об'єднання двох резервуарів

Два резервуари R₁ та R₂ (кожен обробив M₁ та M₂ кандидатів) можна об'єднати в один незміщений резервуар:

Merge(R₁, R₂) → R_out: R_out = новий резервуар Update(R_out, R₁.y, R₁.w_sum) // трактуй семпл R₁ як одного кандидата Update(R_out, R₂.y, R₂.w_sum) // з вагою, що дорівнює його накопиченому w_sum R_out.M = R₁.M + R₂.M // R_out.y — дійсний семпл, розподілений ∝ w_sum об'єднаного потоку

Ця операція об'єднання — основа просторового повторного використання: резервуари сусідніх пікселів об'єднуються в резервуар поточного пікселя, фактично розширюючи його множину кандидатів без переобчислення жодних BRDF.

3. Ресемплування за важливістю (RIS)

Ресемплування за важливістю (RIS) пов'язує WRS із рендерингом методом Монте-Карло. Ми хочемо оцінити:

I = ∫ f(x) dx = ∫ p̂(x) / p(x) · p(x) dx // де p̂(x) ∝ f_r(x) · L_e(x) · G(x) (ідеальна цільова PDF) // p(x) = рівномірний вибір джерела (вихідна PDF)

RIS генерує M кандидатів {xᵢ} з вихідної pdf p(x), призначає вагу wᵢ = p̂(xᵢ)/p(xᵢ), запускає WRS для вибору одного семпла z, потім оцінює I як:

I ≈ (1/M) · (f(z) / p̂(z)) · Σᵢ wᵢ = f(z)/p̂(z) · W_sum/M // Це незміщений оцінювач I, коли: // 1. p̂(x) > 0 всюди, де f(x) > 0 // 2. z вибирається з імовірністю, пропорційною wᵢ (тобто через WRS)

Критично, що цільова PDF p̂ може апроксимувати ідеальну важливість, пропорційну f, але не зобов'язана бути точно p ∝ f. Навіть груба апроксимація (наприклад, p̂ = L_e/r² без урахування BRDF) кардинально зменшує дисперсію порівняно з рівномірним семплуванням. BRDF обчислюється лише раз для вибраного кандидата z, а не для всіх M кандидатів — утримуючи попіксельну вартість на рівні O(M_initial + 1 обчислення BRDF).

4. Часове повторне використання

У рендерері реального часу кадри корельовані: сцена змінюється мало між кадром n та кадром n−1. ReSTIR використовує це, зберігаючи один резервуар на піксель і повторно використовуючи його між кадрами:

Алгоритм часового повторного використання (на піксель q): 1. Згенеруй M_new кандидатів для пікселя q, побудуй резервуар R_new 2. Знайди відповідний піксель q' у попередньому кадрі (через репроєкцію: спроєктуй світову позицію q за допомогою матриці виду попереднього кадру) 3. Отримай R_prev з буфера часових резервуарів у q' 4. Об'єднай: R_temporal = Merge(R_new, min(R_prev, 20·M_new) кандидатів) // Обмеж R_prev.M на 20×M_new, щоб запобігти часовому відставанню при зміні сцени 5. Збережи R_temporal назад у часовий буфер 6. Обчисли вибраний семпл з R_temporal для кольору пікселя цього кадру

За M_new = 32 початкових кандидатів та часового накопичення, обмеженого M = 32 × 20 = 640 ефективними кандидатами, кожен піксель має перевагу семплування 640 кандидатів ціною генерування лише 32 — 20-кратне зменшення дисперсії безкоштовно.

Репроєкція та розкриття

Репроєкція зазнає невдачі, коли: (1) геометрія під пікселем змінюється (розкриття), (2) нормаль поверхні чи матеріал змінюються (нестабільність геометрії) або (3) раптово з'являється яскраве джерело світла (часове відставання). Усі три виявляються через:

5. Просторове повторне використання та зміщення

Після часового повторного використання резервуар кожного пікселя додатково об'єднується з k випадково вибраними сусідами (типово k = 5):

Просторове повторне використання (на піксель q): R_q = часовий резервуар q для j = 1..k: q_j = випадковий сусід у радіусі r (типово r = 30 пікселів) if similar_surface(q, q_j): // перевірка глибини + нормалі R_q = Merge(R_q, R_q_j) Вивід: R_q.y, обчислений для шейдингу пікселя q

Просторове повторне використання фактично помножує бюджет семплів на k+1 — але воно вносить зміщення, якщо його не скоригувати. Зміщення виникає тому, що сусідній піксель q_j може мати іншу видиму поверхню, ніж q: семпл світла y, вибраний у q_j, може бути затулений або геометрично неузгоджений у точці поверхні q. Використання w_sum резервуара R_q_j (обчисленого на поверхні q_j), наче він дійсний у q, переоцінює внесок.

Неформальна інтуїція зміщення: припустимо, q у тіні, а q_j на прямому світлі. q_j вибрав яскравий семпл світла з високою вагою. Об'єднання цього резервуара в q та використання високого w_sum q_j як ваги MIS хибно призначає високу імовірність семплу, що насправді дає нульове освітлення в q. Без корекції оцінювач має додатне зміщення — зображення видається надто яскравим поблизу меж тіней.

6. Корекція зміщення та ваги MIS

Оригінальна стаття ReSTIR DI (Bitterli et al., SIGGRAPH 2020) запропонувала два підходи до корекції зміщення:

Метод 1: зі зміщенням (наближений)

Прийми зміщення та зауваж, що воно переважно проявляється поблизу меж тіней. Для багатьох застосувань (ігри, превізуалізація реального часу) зміщення візуально прийнятне й дає найпростішу реалізацію.

Метод 2: незміщена корекція на основі MIS

Заміни w_sum кожного резервуара версією, зваженою за MIS, що враховує імовірність того, що вибраний семпл був би вибраний усіма резервуарами-учасниками:

// Незміщене поєднання з вагами MIS (Talbot et al., 2005 / Bitterli 2020) // При об'єднанні k+1 резервуарів {R₀, R₁, ..., Rₖ} з пікселів {q₀, ..., qₖ}: W_q = (1 / p̂_q(y)) · (1 / Z) де Z вибирається так, щоб оцінювач був незміщеним: Z = Σⱼ mⱼ · p̂_qⱼ(y) // сума обчислень цільової PDF у всіх сусідів mⱼ = Rⱼ.M, якщо y — «дійсний» семпл у пікселі qⱼ (перевірка видимості + тест поверхні) = 0 інакше // Один тіньовий промінь на об'єднання потрібен, щоб перевірити видимість у кожного сусіда — // що становить O(k) променів на піксель, прийнятно для k ≤ 5

Альтернативний незміщений підхід (Wyman & Panteleev, 2021 — «Rearchitecting Spatiotemporal Resampling for Production») уникає зайвих тіньових променів, використовуючи лише тест геометричної подібності замість повної видимості. Це вносить помірне зміщення на межах затулення, але значно швидше.

Ваги впевненості

Практичне ускладнення: після часового накопичення R_prev.M може бути дуже великим (тисячі), домінуючи у просторовому об'єднанні. Bitterli et al. рекомендують обмежувати M, щоб запобігти домінуванню одного резервуара над усіма сусідами:

R_temporal.M = min(R_temporal.M, C · M_new) C = 20 — стандартне обмеження (20× від початкової кількості кандидатів)

7. ReSTIR DI, GI та PT

Фреймворк ReSTIR було розширено від прямого освітлення до повного глобального освітлення:

ReSTIR DI (пряме освітлення)

Оригінальна стаття 2020 року. Семпли: одне площинне джерело світла на резервуар. Цільова PDF: p̂ = L_e · G · |cos θ| / r² (без тіней). Тіньовий промінь обчислюється лише для кінцевого вибраного кандидата. Досягає якості, еквівалентної 128 кандидатам світла на піксель, ціною 1 тіньового променя на піксель з просторовим+часовим повторним використанням.

Постачається в RTX Direct Illumination SDK від NVIDIA (RTXDI) та широко використовується в іграх та офлайн-інструментах превізуалізації з 2021 року.

ReSTIR GI (глобальне освітлення)

Ouyang et al. (SIGGRAPH 2021). Розширює ReSTIR до сегментів шляхів: замість семплування прямого світла кожен резервуар зберігає повний шлях першого відбиття (x_v → x_s, де x_s — точка вторинного влучання з її яскравістю L_i(x_s)). Цільова PDF — p̂(x_s) = L_i(x_s) · f_r(x_v → x_s) · |cos θ|.

Семпл резервуара y = (x_s, L_i, n_s) // вторинне влучання + вхідна яскравість + нормаль Цільова PDF: p̂(y | x_v) = ||L_i|| · f_r(ω_i, ω_o) · |cos θᵢ| // Просторове повторне використання: сусід q_j обчислює p̂(y | x_v_j) — чи дає спільна // вторинна точка x_s хороший внесок з поверхні q_j? // Якщо геометричний тест проходить, об'єднай резервуар.

ReSTIR GI досягає якості GI з 1 відбиттям, порівнянної з 16 spp у одному відбитті на швидкостях реального часу. Він живить режим «Lumen Hardware Lumen» в Unreal Engine 5.2+.

ReSTIR PT (трасування шляхів)

Lin et al., SIGGRAPH 2022. Розширює до повних шляхів довільної глибини. Кожен резервуар зберігає цілий світловий шлях як послідовність вершин {x₁, x₂, ..., xₖ}. Відображення зсуву з переприєднанням (натхненне трасуванням шляхів MCMC) дозволяє сусіднім пікселям ділити префікси шляхів, водночас коректно зсуваючи суфікс шляху до нового початку.

ReSTIR PT досягає когерентного глобального освітлення (каустики, взаємовідбиття, дзеркальні ланцюги) на еквіваленті 1–4 spp, уможливлюючи трасування шляхів офлайн-якості на споживчих GPU майже в реальному часі (1–10 fps для продакшн-сцен).

Варіант Рік Семпл резервуара Ефективний еквів. spp Ключова вартість
ReSTIR DI 2020 1 кандидат площинного світла 128–512 spp прямого світла 1 тіньовий промінь/піксель
ReSTIR GI 2021 1 вторинне влучання шляху 8–32 spp GI першого відбиття 1 вторинний промінь/піксель
ReSTIR PT 2022 Повний шлях з k відбиттями 4–16 spp повного шляху 1 первинний + k променів переприєднання

8. Нотатки щодо реалізації у WebGPU

Мінімальна реалізація ReSTIR DI у WebGPU потребує таких проходів рендерингу на кадр:

Конвеєр (на кадр): Прохід 1: G-буфер (растеризація → текстури альбедо, нормалі, глибини, векторів руху) Прохід 2: Початкові кандидати (обчислювальний шейдер, 1 робоча група на піксель) → Семплуй M_init джерел з рівномірного розподілу → Побудуй резервуар на піксель через WRS → Запиши в reservoirs_initial[] Прохід 3: Часове повторне використання (обчислювальний шейдер) → Репроєктуй за допомогою векторів руху → Об'єднай reservoirs_initial з reservoirs_prev_temporal[] → Запиши в reservoirs_temporal[] (також стає prev наступного кадру) Прохід 4: Просторове повторне використання (обчислювальний шейдер, можливо 2+ проходи) → Для кожного пікселя семплуй k сусідів → Об'єднай після перевірки геометрії → Запиши в reservoirs_spatial[] Прохід 5: Шейдинг (обчислювальний або фрагментний шейдер) → Для кожного пікселя обчисли вибраний семпл резервуара y: colour = f_r(y) · L_e(y) · G(y) · reservoir.W_sum / M → Запиши в текстуру виводу Прохід 6: Знешумлення + TAA (опціонально)

Структура резервуара у WGSL

// Запис буфера резервуара у WGSL
struct Reservoir {
  y_light_index : u32,    // індекс вибраного кандидата світла
  y_uv          : vec2f,  // UV на поверхні світла (для площинних джерел)
  w_sum         : f32,    // накопичена сума ваг
  M             : u32,    // кількість побачених кандидатів
  W             : f32,    // незміщена вага: w_sum / (M · p̂(y))
};

@group(0) @binding(0) var<storage, read_write> reservoirs : array<Reservoir>;
@group(0) @binding(1) var<storage, read>       lights      : array<Light>;
@group(0) @binding(2) var                        g_depth     : texture_2d<f32>;
@group(0) @binding(3) var                        g_normal    : texture_2d<f32>;

// Оновлення WRS (виклик для кожного кандидата xᵢ з вагою wᵢ)
fn wrs_update(r: ptr<function, Reservoir>, x: u32, w: f32, rng: f32) {
  (*r).w_sum += w;
  (*r).M     += 1u;
  if rng < w / (*r).w_sum {
    (*r).y_light_index = x;
  }
}

// Об'єднання двох резервуарів (для просторового/часового поєднання)
fn merge(dst: ptr<function, Reservoir>, src: Reservoir,
         p_hat_src: f32, rng: f32) {
  let w = p_hat_src * src.W * f32(src.M);
  wrs_update(dst, src.y_light_index, w, rng);
  (*dst).M += src.M;
}

// Обчислювальний шейдер: генерація початкових кандидатів
@compute @workgroup_size(8, 8)
fn generate_initial(@builtin(global_invocation_id) gid: vec3u) {
  let px = gid.xy;
  let res_idx = px.y * u32(screen_size.x) + px.x;

  var r : Reservoir;
  r.w_sum = 0.0; r.M = 0u; r.W = 0.0;

  // Читаємо поверхню G-буфера в цьому пікселі
  let depth  = textureLoad(g_depth,  px, 0).r;
  let normal = textureLoad(g_normal, px, 0).xyz * 2.0 - 1.0;
  let world_pos = reconstruct_world(px, depth);

  var rng = pcg_hash(res_idx ^ (frame_index * 1013904223u));

  for (var i = 0u; i < INITIAL_CANDIDATES; i++) {
    let light_idx = rand_u32(&rng) % num_lights;
    let light = lights[light_idx];
    // Цільова PDF без тіней: L_e · |cos θ| / r²
    let to_light = light.position - world_pos;
    let dist2    = dot(to_light, to_light);
    let n_dot_l  = max(0.0, dot(normal, normalize(to_light)));
    let p_hat    = length(light.emission) * n_dot_l / dist2;
    let w        = p_hat / (1.0 / f32(num_lights));   // вага = p̂ / source_pdf
    wrs_update(&r, light_idx, w, fract(rand_f32(&rng)));
  }

  // Обчислюємо незміщену вагу W = w_sum / (M · p̂(y))
  let sel = lights[r.y_light_index];
  let to_sel  = sel.position - world_pos;
  let dist2s  = dot(to_sel, to_sel);
  let p_hat_y = length(sel.emission) * max(0.0, dot(normal, normalize(to_sel))) / dist2s;
  r.W = select(0.0, r.w_sum / (f32(r.M) * p_hat_y), p_hat_y > 0.0);

  reservoirs[res_idx] = r;
}
    
Стан у продакшні: ReSTIR DI постачається в NVIDIA RTXDI (відкритий код, GitHub), Unreal Engine 5 (Hardware Lumen використовує ReSTIR GI) і є основним алгоритмом семплування в іграх на кшталт Cyberpunk 2077 Overdrive Mode та Alan Wake 2. Оригінальна стаття SIGGRAPH 2020 вільно доступна на сайті NVIDIA Research і є однією з найбільш цитованих статей з рендерингу реального часу десятиліття.