<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Блог Small Water</title>
    <link>https://smallwater.eu/ua/blog/</link>
    <description>Застосунки, вебінструменти та ігри від невеликої софтверної компанії зі Словаччини.</description>
    <language>uk</language>
    <atom:link href="https://smallwater.eu/ua/blog/feed.xml" rel="self" type="application/rss+xml" />
    <lastBuildDate>Mon, 09 Nov 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>Як зробити LOD для тисяч анімованих персонажів у Unity</title>
      <link>https://smallwater.eu/ua/blog/unity-lod-vertex-animation-crowd/</link>
      <guid isPermaLink="true">https://smallwater.eu/ua/blog/unity-lod-vertex-animation-crowd/</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <category>Tower Defense</category>
      <description>Посібник із LOD для натовпу в Unity з vertex animation textures: чому важливі вершини, а не трикутники, LOD як нова сітка і вибір рівня для кожної сутності.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/cover.webp" /><img src="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/cover.jpg" alt="Три версії одного персонажа-скелета зі зменшенням кількості трикутників зліва направо" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Зміст"><p class="post-toc-title">Зміст</p><ol><li><a href="#чому-згенеровании-lod-не-допомагає-натовпу" data-ga="blog_toc_clicked" data-ga-heading-id="чому-згенеровании-lod-не-допомагає-натовпу">Чому згенерований LOD не допомагає натовпу</a></li><li><a href="#рахуите-вершини-а-не-трикутники" data-ga="blog_toc_clicked" data-ga-heading-id="рахуите-вершини-а-не-трикутники">Рахуйте вершини, а не трикутники</a></li><li><a href="#крок-1-зменште-кількість-вершин-у-lod-0" data-ga="blog_toc_clicked" data-ga-heading-id="крок-1-зменште-кількість-вершин-у-lod-0">Крок 1: Зменште кількість вершин у LOD 0</a></li><li><a href="#крок-2-lod-1-зі-спрощення-зі-спільною-текстурою" data-ga="blog_toc_clicked" data-ga-heading-id="крок-2-lod-1-зі-спрощення-зі-спільною-текстурою">Крок 2: LOD 1 зі спрощення зі спільною текстурою</a></li><li><a href="#крок-3-lod-2-як-нова-сітка-приєднана-до-оригіналу" data-ga="blog_toc_clicked" data-ga-heading-id="крок-3-lod-2-як-нова-сітка-приєднана-до-оригіналу">Крок 3: LOD 2 як нова сітка, приєднана до оригіналу</a></li><li><a href="#крок-4-перевіряите-дірки-автоматично" data-ga="blog_toc_clicked" data-ga-heading-id="крок-4-перевіряите-дірки-автоматично">Крок 4: Перевіряйте дірки автоматично</a></li><li><a href="#крок-5-вибираите-рівень-для-кожного-персонажа-окремо" data-ga="blog_toc_clicked" data-ga-heading-id="крок-5-вибираите-рівень-для-кожного-персонажа-окремо">Крок 5: Вибирайте рівень для кожного персонажа окремо</a></li><li><a href="#результати-и-обмеження" data-ga="blog_toc_clicked" data-ga-heading-id="результати-и-обмеження">Результати й обмеження</a></li></ol></nav><p>LOD для тисяч анімованих персонажів у Unity будуйте за кількістю вершин, а не трикутників: у vertex animation textures анімація рахується для кожної вершини. Нижчі рівні підготуйте вручну, вершини найнижчого рівня приєднайте до поверхні оригіналу, а рівень вибирайте для кожного персонажа окремо. У нашій tower defense грі для ПК це при 3 000 ворогів підняло FPS у наближенні до натовпу з 62 до 120.</p>
<p>Це стосується ігор, де тисячі персонажів рухаються на екрані одночасно: tower defense, стратегій і survival. Наша гра зроблена в Unity з ECS і є в <a href="https://smallwater.eu/ua/#referencie">референціях</a>. LOD означає <a href="https://en.wikipedia.org/wiki/Level_of_detail_(computer_graphics)" target="_blank" rel="noopener">level of detail</a>: віддаленого персонажа малюють із простішої сітки. На зображеннях справжні сітки нашого ворога-скелета, а не кадри з гри.</p>
<h2 id="чому-згенеровании-lod-не-допомагає-натовпу"><a class="heading-anchor" href="#чому-згенеровании-lod-не-допомагає-натовпу" aria-hidden="true" tabindex="-1">#</a>Чому згенерований LOD не допомагає натовпу</h2>
<p>Unity вміє генерувати рівні LOD під час імпорту моделі (функція <a href="https://docs.unity3d.com/6000.2/Documentation/Manual/lod/mesh-lod-introduction.html" target="_blank" rel="noopener">Mesh LOD</a>). Для тисяч персонажів це має дві проблеми.</p>
<p>Перша: вигляд. Автоматичне спрощення рвало тонкі кінцівки й ламало UV, тобто прив&#39;язку текстури до сітки.</p>
<p>Друга: ціна анімації. Звичайна скелетна анімація надто дорога для тисяч сутностей, тому замість неї використовують vertex animation textures (VAT). VAT є текстурою, де кожен стовпець належить одній вершині, а кожен рядок одному кадру анімації. Шейдер читає положення вершини з текстури, тож ціна анімації залежить від кількості прочитаних вершин, зокрема й під час малювання тіней.</p>
<p>Верхню межу показує тест із примусовим згенерованим рівнем на всіх персонажах (Unity генерує також рівні 3 і 4, build, 1080p, 3 000 ворогів, тіні ввімкнено):</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Примусовий рівень</th>
<th>Трикутники</th>
<th>FPS</th>
</tr>
</thead>
<tbody><tr>
<td>LOD 0</td>
<td>11 мільйонів</td>
<td>72</td>
</tr>
<tr>
<td>LOD 1</td>
<td>6 мільйонів</td>
<td>96</td>
</tr>
<tr>
<td>LOD 2</td>
<td>3,68 мільйона</td>
<td>120</td>
</tr>
<tr>
<td>LOD 4</td>
<td>1,43 мільйона</td>
<td>201</td>
</tr>
</tbody></table>
</div>
<p>Запас для прискорення великий. Його треба використати без помітних дірок і стрибків між рівнями.</p>
<h2 id="рахуите-вершини-а-не-трикутники"><a class="heading-anchor" href="#рахуите-вершини-а-не-трикутники" aria-hidden="true" tabindex="-1">#</a>Рахуйте вершини, а не трикутники</h2>
<p>Удвічі менше трикутників саме по собі майже не допомагає. LOD із половиною трикутників і 996 із початкових 1 521 вершин заощадив 1,9 мс на кадр, LOD із 217 вершинами 6,4 мс. На кожному рівні стежте за обома числами.</p>
<p>Вершин часто буває забагато. Вершина подвоюється на кожному шві UV, бо одне положення тоді потребує двох текстурних координат. Наші моделі з AI-генератора мали UV, порізані на сотні острівців: скелет мав 1 521 вершину, але лише 608 різних поєднань положення й нормалі.</p>
<h2 id="крок-1-зменште-кількість-вершин-у-lod-0"><a class="heading-anchor" href="#крок-1-зменште-кількість-вершин-у-lod-0" aria-hidden="true" tabindex="-1">#</a>Крок 1: Зменште кількість вершин у LOD 0</h2>
<p>Перша економія не коштує жодної деталі. Об&#39;єднайте вершини з однаковим положенням і схожою нормаллю (різниця до 25°), розгорніть UV заново зі швами лише на гострих ребрах (понад 70°) і перерахуйте текстуру зі старої по трикутниках. Скелет зменшився з 1 521 до 1 090 вершин, інші типи ворогів на 19-29 %, за тієї ж форми.</p>
<p>Потім записуйте у VAT лише стовпці вершин, які справді використовує якийсь рівень. Це заощадило 40-60 % пам&#39;яті анімацій.</p>
<h2 id="крок-2-lod-1-зі-спрощення-зі-спільною-текстурою"><a class="heading-anchor" href="#крок-2-lod-1-зі-спрощення-зі-спільною-текстурою" aria-hidden="true" tabindex="-1">#</a>Крок 2: LOD 1 зі спрощення зі спільною текстурою</h2>
<p>LOD 1 є LOD 0, спрощеним приблизно до 52 % трикутників. Спрощуйте частинами (кожне поєднання кістки та зв&#39;язної частини сітки) і блокуйте вершини на межах частин. Голові дайте більший бюджет, бо силует голови око впізнає в натовпі.</p>
<p>Важливо залишити текстуру LOD 0. Рівень із власною текстурою або іншою формою видно під час переходу навіть за схожого силуету.</p>
<p>Нижче приблизно 50 % трикутників спрощення з наявних вершин рве тонкі частини й окремі шматки. Далі потрібен інший підхід.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/levels.webp" /><img src="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/levels.png" alt="Три версії персонажа-скелета поруч зі зменшенням кількості трикутників" width="2400" height="1240" loading="lazy" decoding="async" /></picture><figcaption>Той самий скелет на трьох рівнях: 873, 448 і 330 трикутників, 1 090, 705 і 520 вершин.</figcaption></figure>
<h2 id="крок-3-lod-2-як-нова-сітка-приєднана-до-оригіналу"><a class="heading-anchor" href="#крок-3-lod-2-як-нова-сітка-приєднана-до-оригіналу" aria-hidden="true" tabindex="-1">#</a>Крок 3: LOD 2 як нова сітка, приєднана до оригіналу</h2>
<p>Найнижчий рівень є новою замкненою сіткою. Потовщіть оригінал шаром, пройдіть voxel remesh і спростіть до цільової кількості (у нас 200-460 трикутників залежно від типу ворога). Результат обтягніть на поверхню оригіналу, UV зробіть автоматично. Текстуру запечіть з оригіналу в тій самій щільності, що й LOD 0, щоб збігалися й mipmap.</p>
<p>Нова сітка не має ваг анімації. Тому кожну її вершину приєднайте до найближчого трикутника оригіналу. Збережіть три стовпці цього трикутника й барицентричні ваги (частки, які задають положення точки в трикутнику).</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/attach.webp" /><img src="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/attach.png" alt="Схема: червона вершина спрощеної сітки з&#39;єднана пунктиром із трьома вершинами трикутника оригіналу" width="1800" height="840" loading="lazy" decoding="async" /></picture><figcaption>Вершина нижчого рівня (червона) рухається за трьома вершинами найближчого трикутника оригіналу.</figcaption></figure>
<p>Стовпець приєднаної вершини у VAT є зваженою сумою положень і нормалей цих трьох вершин у кожному кадрі. Шейдер нічого особливого не робить, приєднана вершина лише ще один стовпець.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">для кожного кадру f:
    VAT[приєднана, f] = w0 * VAT[a, f] + w1 * VAT[b, f] + w2 * VAT[c, f]</code></pre>
<h2 id="крок-4-перевіряите-дірки-автоматично"><a class="heading-anchor" href="#крок-4-перевіряите-дірки-автоматично" aria-hidden="true" tabindex="-1">#</a>Крок 4: Перевіряйте дірки автоматично</h2>
<p>Зображення з програми для моделювання надійно дірок не покажуть. Достатньо простої перевірки: відрендеріть оригінал і кандидата з 11 напрямків із роздільністю 120 px і порівняйте силуети.</p>
<p>Діркою вважайте пікселі оригіналу всередині контуру кандидата, які кандидат не покриває, і пікселі кандидата більш ніж на 2 px поза контуром оригіналу. У нас межа 1 % дірок і 4-6 % втрат. Перевірка має враховувати відсікання задніх граней залежно від матеріалу. Межі ми встановили за тим, де переходи в грі було видно оком.</p>
<h2 id="крок-5-вибираите-рівень-для-кожного-персонажа-окремо"><a class="heading-anchor" href="#крок-5-вибираите-рівень-для-кожного-персонажа-окремо" aria-hidden="true" tabindex="-1">#</a>Крок 5: Вибирайте рівень для кожного персонажа окремо</h2>
<p>Перемикання рівня для всієї мапи одночасно помітне. Якщо вибирати для кожної сутності окремо, переходи розподіляються й губляться в натовпі.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Уся мапа одночасно</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/select-global.webp" /><img src="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/select-global.png" alt="Натовп точок одного кольору, усі персонажі мають однаковий рівень" width="1120" height="840" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Для кожної сутності</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/select-entity.webp" /><img src="https://smallwater.eu/blog/unity-lod-vertex-animation-crowd/select-entity.png" alt="Натовп точок смугами за відстанню від камери, близькі червоні, далекі сірі" width="1120" height="840" loading="lazy" decoding="async" /></picture></div></div><figcaption>Вигляд згори, камера внизу. Колір позначає рівень LOD: червоний 0, помаранчевий 1, сірий 2.</figcaption></figure>
<p>Застереження щодо версії: документація Unity 6.2 зазначає, що Entities Graphics завжди використовує LOD 0. Вибір для кожної сутності за формулою нижче ми застосовуємо в Unity 6.6, тому заздалегідь перевірте поведінку своєї версії:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">LOD = log2(d * C / D) * нахил + зсув
C = поріг * 2 * tan(fov / 2) / висота_кадру</code></pre>
<p>Тут <code>d</code> означає відстань від камери, а <code>D</code> діаметр світових меж сутності. Нахил і зсув підберіть так, щоб рівень 1 починався на 93,3, а рівень 2 на 167,9 метра (камера з fov 30° і 1440 px). Глобальний поріг LOD встановіть на висоту кадру / 1440, тоді відстані не залежать від роздільності.</p>
<p>Дві помилки, яких варто уникати:</p>
<ul>
<li><strong>Масштаб.</strong> Діаметр <code>D</code> рахуйте зі світових меж, тобто з масштабом prefab. Сітки для VAT часто мають малі одиниці, а prefab збільшує їх у 2-4 рази. Без цього гра весь час малює LOD 0 і нічого не повідомляє. Перевірте це за кількістю трикутників у звичайному режимі порівняно з примусовим LOD. Поворот сутності збільшує осьові межі, тому в нас діаметр множиться на коефіцієнт 1,22.</li>
<li><strong>Запікання.</strong> Крива вибору зберігається під час запікання SubScene, і запікання не помічає зміни в сітці. Після її редагування примусово перезапечіть SubScene.</li>
</ul>
<h2 id="результати-и-обмеження"><a class="heading-anchor" href="#результати-и-обмеження" aria-hidden="true" tabindex="-1">#</a>Результати й обмеження</h2>
<p>Build для macOS, 2560 × 1440, 3 000 ворогів, тіні ввімкнено, гра зупинена після поразки (анімації VAT далі працюють, тож вимірювання дійсне для графіки):</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Сцена</th>
<th>До</th>
<th>Після</th>
</tr>
</thead>
<tbody><tr>
<td>Уся мапа</td>
<td>90 FPS</td>
<td>130 FPS</td>
</tr>
<tr>
<td>Наближення до натовпу</td>
<td>62 FPS</td>
<td>120 FPS</td>
</tr>
</tbody></table>
</div>
<p>На <a href="https://www.steamdeck.com/en/tech" target="_blank" rel="noopener">Steam Deck</a> (1280 × 800, 8 обчислювальних блоків RDNA 2) це 75-80 FPS на всій мапі. Найближчий кадр у натовпі має 33 FPS із високою якістю тіней та 64 без тіней. Тіні, отже, найбільша стаття витрат: LOD знижує і їхню ціну, але вимкнення все одно дає великий стрибок.</p>
<p>Чого LOD не вирішує: перевірка дірок працює в T-позі, тож не помітить розтягнення в екстремальних позах. Один тип ворогів досі перевищує межу дірок у LOD 2. На обчислення руху орди LOD не впливає, про це стаття про <a href="https://smallwater.eu/ua/blog/unity-dots-horde-navigation/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/unity-lod-vertex-animation-crowd/" data-ga-destination="/ua/blog/unity-dots-horde-navigation/" data-ga-placement="inline">навігацію орди в Unity DOTS</a>, про реакції на вибухи <a href="https://smallwater.eu/ua/blog/fake-ragdoll-unity-dots/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/unity-lod-vertex-animation-crowd/" data-ga-destination="/ua/blog/fake-ragdoll-unity-dots/" data-ga-placement="inline">fake ragdoll</a>.</p>
<p>Потрібна допомога з продуктивністю гри в Unity або симуляцією натовпу? <a href="https://smallwater.eu/ua/#kontakt">Напишіть нам</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Як додати glow і 2D-світло в стару 2D-гру на Unity URP</title>
      <link>https://smallwater.eu/ua/blog/dreamica-remaster-unity-urp-glow/</link>
      <guid isPermaLink="true">https://smallwater.eu/ua/blog/dreamica-remaster-unity-urp-glow/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Dreamica</category>
      <description>Як дати старій 2D-грі на Unity URP вибірковий glow через Bloom і HDR, динамічне 2D-світло та чіткий UI для сучасних екранів. Покроково на прикладі Dreamica.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/cover.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/cover.jpg" alt="Три кадри з гри Dreamica: привид женеться за бульбашкою, білка з гарматою і лазери, що світяться в лісі" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Зміст"><p class="post-toc-title">Зміст</p><ol><li><a href="#чому-старого-повноекранного-glow-недостатньо" data-ga="blog_toc_clicked" data-ga-heading-id="чому-старого-повноекранного-glow-недостатньо">Чому старого повноекранного glow недостатньо</a></li><li><a href="#крок-1-переведіть-проєкт-на-urp" data-ga="blog_toc_clicked" data-ga-heading-id="крок-1-переведіть-проєкт-на-urp">Крок 1: Переведіть проєкт на URP</a><ol><li><a href="#сірии-екран-налаштуите-camera-stack" data-ga="blog_toc_clicked" data-ga-heading-id="сірии-екран-налаштуите-camera-stack">Сірий екран: налаштуйте camera stack</a></li><li><a href="#sprite-ігнорують-світло-переидіть-на-2d-renderer" data-ga="blog_toc_clicked" data-ga-heading-id="sprite-ігнорують-світло-переидіть-на-2d-renderer">Sprite ігнорують світло: перейдіть на 2D renderer</a></li><li><a href="#частинки-та-старі-плагіни" data-ga="blog_toc_clicked" data-ga-heading-id="частинки-та-старі-плагіни">Частинки та старі плагіни</a></li></ol></li><li><a href="#крок-2-вибірковии-glow-через-hdr-колір-і-bloom-з-порогом-1" data-ga="blog_toc_clicked" data-ga-heading-id="крок-2-вибірковии-glow-через-hdr-колір-і-bloom-з-порогом-1">Крок 2: Вибірковий glow через HDR-колір і Bloom з порогом 1</a><ol><li><a href="#карта-емісіі-світиться-лише-частина-sprite" data-ga="blog_toc_clicked" data-ga-heading-id="карта-емісіі-світиться-лише-частина-sprite">Карта емісії: світиться лише частина sprite</a></li><li><a href="#пульсація-в-ступенях-експозиціі" data-ga="blog_toc_clicked" data-ga-heading-id="пульсація-в-ступенях-експозиціі">Пульсація в ступенях експозиції</a></li></ol></li><li><a href="#крок-3-динамічне-2d-світло-навколо-гравця" data-ga="blog_toc_clicked" data-ga-heading-id="крок-3-динамічне-2d-світло-навколо-гравця">Крок 3: Динамічне 2D-світло навколо гравця</a><ol><li><a href="#однаковии-shader-на-суміжних-поверхнях" data-ga="blog_toc_clicked" data-ga-heading-id="однаковии-shader-на-суміжних-поверхнях">Однаковий shader на суміжних поверхнях</a></li><li><a href="#спалах-перехід-до-піку-замість-додавання" data-ga="blog_toc_clicked" data-ga-heading-id="спалах-перехід-до-піку-замість-додавання">Спалах: перехід до піку замість додавання</a></li></ol></li><li><a href="#крок-4-чіткии-ui-для-сучасних-екранів" data-ga="blog_toc_clicked" data-ga-heading-id="крок-4-чіткии-ui-для-сучасних-екранів">Крок 4: Чіткий UI для сучасних екранів</a></li><li><a href="#обмеження-нестиснуті-текстури" data-ga="blog_toc_clicked" data-ga-heading-id="обмеження-нестиснуті-текстури">Обмеження: нестиснуті текстури</a></li></ol></nav><p>Стара 2D-гра на Unity з вбудованим render pipeline на сучасному телефоні виглядає пласко: освітлення немає, glow або відсутній, або підсвічує весь кадр разом з UI, а sprite інтерфейсу розмиті. В Unity URP це вирішують три речі: Bloom з порогом 1 та HDR-колір лише на об&#39;єктах, які мають світитися, 2D-світло з 2D renderer і UI з більшими sprite, що враховує безпечну зону екрана.</p>
<p>Покажемо це на нашій грі Dreamica (<a href="https://smallwater.eu/dreamica-ios">App Store</a>, <a href="https://smallwater.eu/dreamica-android">Google Play</a>). Це пригода з падінням, у якій ви малюєте пальцем лінії й ведете ними бульбашку зі сплячою дитиною крізь чотири світи. Спочатку гру створили в Unity 5.3, UI працював на плагіні NGUI, glow давав плагін MK Glow, а графіку готували для iPhone 7 з екраном 1334 × 750 пікселів. Геймплей лишився тим самим, змінилася тільки графіка.</p>
<p>Стару гру часто відкривають через магазин, а не через графіку. Google Play з серпня 2019 року <a href="https://android-developers.googleblog.com/2019/01/get-your-apps-ready-for-64-bit.html" target="_blank" rel="noopener">вимагає 64-бітні версії</a>, і гра лише з 32-бітним build з нього зникає. Саме так сталося з Dreamica. Якщо ви все одно переносите проєкт на Unity 6, графіка стає відносно дешевим бонусом.</p>
<h2 id="чому-старого-повноекранного-glow-недостатньо"><a class="heading-anchor" href="#чому-старого-повноекранного-glow-недостатньо" aria-hidden="true" tabindex="-1">#</a>Чому старого повноекранного glow недостатньо</h2>
<p>Старші glow-плагіни працюють як повноекранний ефект. Вони підсвічують усе світле, зокрема іконки кнопок у меню. У Dreamica під MK Glow в Unity 6 іконки майже зникли. Офіційний пакет Post Processing Stack v2 замінює його лише для вбудованого pipeline, в URP він уже не застосовується. Якщо вам потрібен glow, яким можна керувати для кожного матеріалу, і динамічне 2D-світло, переходьте на URP і використовуйте вбудований у нього Bloom.</p>
<h2 id="крок-1-переведіть-проєкт-на-urp"><a class="heading-anchor" href="#крок-1-переведіть-проєкт-на-urp" aria-hidden="true" tabindex="-1">#</a>Крок 1: Переведіть проєкт на URP</h2>
<p>Після переходу на URP стара 2D-гра майже напевно натрапить на дві пастки. Обидві виглядають так, ніби картинка зламана або точно така сама, як раніше, хоча shader у порядку.</p>
<h3 id="сірии-екран-налаштуите-camera-stack"><a class="heading-anchor" href="#сірии-екран-налаштуите-camera-stack" aria-hidden="true" tabindex="-1">#</a>Сірий екран: налаштуйте camera stack</h3>
<p>Якщо вікно Game в Editor показує лише сіру площину, а вікно Scene і одноразовий рендер камери виглядають правильно, проблема не в shader, а в тому, як поєднуються камери. Старі ігри часто мають дві камери, одну для світу й одну для UI, причому камера UI очищає лише глибину. Так камери накладаються у вбудованому pipeline, і URP цього не підтримує. Йому потрібен явний camera stack: одна камера базова (Base), інші є накладками (Overlay) у її стеку. Налаштуйте його в кожній сцені, де камери накладаються.</p>
<h3 id="sprite-ігнорують-світло-переидіть-на-2d-renderer"><a class="heading-anchor" href="#sprite-ігнорують-світло-переидіть-на-2d-renderer" aria-hidden="true" tabindex="-1">#</a>Sprite ігнорують світло: перейдіть на 2D renderer</h3>
<p>Sprite з освітлюваним shader не реагують на жодне світло, якщо активний звичайний URP renderer. Він узагалі не обчислює 2D-світло. Перейдіть на 2D renderer. Але й він не малює освітлення, поки в сцені немає хоча б одного 2D-світла. Коли після зміни щось виглядає точно як раніше, спершу перевірте, який renderer працює.</p>
<h3 id="частинки-та-старі-плагіни"><a class="heading-anchor" href="#частинки-та-старі-плагіни" aria-hidden="true" tabindex="-1">#</a>Частинки та старі плагіни</h3>
<p>Частинки на старих shader під 2D renderer малюються чорними або не малюються зовсім. Дайте їм новий простий shader.</p>
<p>Обережно з Asset Store. Під час оновлення assets Editor може сам застосувати «оновлення» старого плагіна, яке додає метадані й перейменовує shader. Матеріали шукають shader за назвою, тож тихо ламаються. Після кожного більшого оновлення assets у старому проєкті перегляньте в системі контролю версій усі змінені файли, навіть ті, з якими зараз не працюєте.</p>
<h2 id="крок-2-вибірковии-glow-через-hdr-колір-і-bloom-з-порогом-1"><a class="heading-anchor" href="#крок-2-вибірковии-glow-через-hdr-колір-і-bloom-з-порогом-1" aria-hidden="true" tabindex="-1">#</a>Крок 2: Вибірковий glow через HDR-колір і Bloom з порогом 1</h2>
<p>Мета в тому, щоб світилися вибрані об&#39;єкти, у Dreamica це намальована лінія, лазери, іскри, світлячки й руни, але не фон і UI. Bloom в URP розмиває й додає все, що яскравіше за його <a href="https://docs.unity3d.com/6/Documentation/Manual/urp/post-processing-bloom.html" target="_blank" rel="noopener">поріг</a>. Встановіть поріг 1 і підніміть колір матеріалів, які мають світитися, вище 1.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">shader лінії:
    колір = текстура × колір вершини × відтінок × інтенсивність    (інтенсивність = 6)

Bloom:
    поріг = 1, сила = 0,3
    світиться лише те, що після обчислення яскравіше за 1</code></pre>
<p>Фон і кнопки мають кольори від 0 до 1, не проходять поріг і лишаються чіткими. Так зникає й проблема з вибіленими іконками. Умова: камера з HDR. Без нього колір обрізається до 1 і нічого не світиться. 2D renderer не показує перемикач HDR в інспекторі камери, тому увімкніть його скриптом.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Низький поріг, увесь кадр</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/glow-fullscreen.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/glow-fullscreen.jpg" alt="Повноекранний glow: світяться лінія, край кнопки та світлі ділянки фону" width="1440" height="840" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Поріг 1, HDR-матеріал</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/glow-selective.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/glow-selective.jpg" alt="Вибірковий glow: світиться лише намальована лінія, кнопка і фон чіткі" width="1440" height="840" loading="lazy" decoding="async" /></picture></div></div><figcaption>Ілюстрація принципу. Ліворуч повноекранний glow з низьким порогом, праворуч світиться лише матеріал з інтенсивністю понад 1.</figcaption></figure>
<p>Коли glow так контрольований, він не мусить бути опціональним. У Dreamica ми прибрали перемикач glow з налаштувань, glow є частиною вигляду гри.</p>
<h3 id="карта-емісіі-світиться-лише-частина-sprite"><a class="heading-anchor" href="#карта-емісіі-світиться-лише-частина-sprite" aria-hidden="true" tabindex="-1">#</a>Карта емісії: світиться лише частина sprite</h3>
<p>Якщо має світитися лише частина sprite, додайте йому карту емісії (emission map): другу, чорно-білу текстуру, яка позначає, яка частина sprite світиться. Лише ця частина отримує колір понад 1, тож у руни сяє серцевина, а не весь контур.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/rune-emission.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/rune-emission.png" alt="Руна, її чорно-біла карта емісії та результат із серцевиною, що світиться" width="1800" height="600" loading="lazy" decoding="async" /></picture><figcaption>Sprite руни, карта емісії (біле світиться) і результат після Bloom.</figcaption></figure>
<h3 id="пульсація-в-ступенях-експозиціі"><a class="heading-anchor" href="#пульсація-в-ступенях-експозиціі" aria-hidden="true" tabindex="-1">#</a>Пульсація в ступенях експозиції</h3>
<p>Якщо світіння має пульсувати, не множте колір лінійно. HDR-матеріал може мати яскравість близько 2 у восьмому степені, і лінійний множник від 0 до 10 на ньому взагалі не помітний. Рахуйте пульс у ступенях експозиції (EV), так само як повзунок інтенсивності у виборі HDR-кольору в Unity:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">колір = відтінок × 2 ^ ev,   ev коливається між мінімумом і максимумом</code></pre>
<h2 id="крок-3-динамічне-2d-світло-навколо-гравця"><a class="heading-anchor" href="#крок-3-динамічне-2d-світло-навколо-гравця" aria-hidden="true" tabindex="-1">#</a>Крок 3: Динамічне 2D-світло навколо гравця</h2>
<p>Додайте гравцеві точкове 2D-світло з м&#39;яким ореолом навколо. У Dreamica одна синусоїда водночас керує яскравістю ореолу, радіусом світла та його інтенсивністю, тож вони пульсують разом. Колір світла може задавати середовище: у лісі білий, на болоті помаранчевий, узимку фіолетовий, у пеклі жовтий. Переведіть фон і перешкоди на освітлювані shader, інакше вони не реагують на світло.</p>
<p>Короткі спалахи світла оживлюють події. Коли стріляє білка з гарматою, спалахує бурштинове світло з інтенсивністю 3, яке гасне за 0,3 секунди, а іскри з дула світяться. Світитися має не все: пил, коли зникає жолудь, навмисно коричневий і не світиться.</p>
<h3 id="однаковии-shader-на-суміжних-поверхнях"><a class="heading-anchor" href="#однаковии-shader-на-суміжних-поверхнях" aria-hidden="true" tabindex="-1">#</a>Однаковий shader на суміжних поверхнях</h3>
<p>Якщо два фони стикуються, вихідні зображення мають однакову яскравість, а шов між ними все одно видно, перевірте shader. У Dreamica початковий фон і нескінченний фон лісу мали різні shader. Один ігнорував відтінок матеріалу 0,733, другий застосовував його й затемнював ліс до 73 %. Рішення: однаковий shader на обох поверхнях.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Два shader</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/seam-before.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/seam-before.jpg" alt="Межа між двома фонами лісу, нижня частина помітно темніша" width="1024" height="1120" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Один shader</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/seam-after.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/seam-after.jpg" alt="Та сама межа, обидва фони мають однакову яскравість" width="1024" height="1120" loading="lazy" decoding="async" /></picture></div></div><figcaption>Межа між початковим фоном і лісом. Ліворуч нижня частина затемнена відтінком 0,733, який верхній shader ігнорує.</figcaption></figure>
<h3 id="спалах-перехід-до-піку-замість-додавання"><a class="heading-anchor" href="#спалах-перехід-до-піку-замість-додавання" aria-hidden="true" tabindex="-1">#</a>Спалах: перехід до піку замість додавання</h3>
<p>Під час переходу до наступного світу світло й ореол спалахують: наростання 0,6 секунди, коротке утримання, згасання 1,4 секунди, і ореол при цьому один раз обертається. Якщо просто додати спалах до пульсу, пік залежить від того, у якій фазі саме зараз синусоїда, і кожен спалах має іншу силу. Натомість плавно переходьте від пульсу до фіксованого піку:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">вага = крива спалаху (від 0 до 1)
значення = пульс + (пік - пульс) × вага</code></pre>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Додавання</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/surge-additive.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/surge-additive.png" alt="Графік інтенсивності: пульс і два спалахи з різною висотою піку" width="1440" height="720" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Перехід до піку</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/surge-lerp.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/surge-lerp.png" alt="Графік інтенсивності: пульс і два спалахи з однаковим піком на пунктирній лінії" width="1440" height="720" loading="lazy" decoding="async" /></picture></div></div><figcaption>Два спалахи в різних фазах пульсу (помаранчевий і синій). Ліворуч піки різні, праворуч обидва досягають однакового значення.</figcaption></figure>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/dreamica/1-chase.jpg" alt="" width="700" height="1515" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Спробуйте</p><h2 class="app-box-title">Dreamica</h2><p>Намальована вручну пригода з падінням, де ви малюєте фізичні лінії, щоб вести чарівну бульбашку. Нотатки з розробки та новини.</p><div class="app-box-links"><a class="btn btn-primary" href="https://smallwater.eu/dreamica-ios" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="dreamica" data-ga-destination="/dreamica-ios" data-ga-app="dreamica" data-ga-store="app_store">App Store</a><a class="btn btn-ghost" href="https://smallwater.eu/dreamica-android" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="dreamica" data-ga-destination="/dreamica-android" data-ga-app="dreamica" data-ga-store="google_play">Google Play</a></div></div></aside>
<h2 id="крок-4-чіткии-ui-для-сучасних-екранів"><a class="heading-anchor" href="#крок-4-чіткии-ui-для-сучасних-екранів" aria-hidden="true" tabindex="-1">#</a>Крок 4: Чіткий UI для сучасних екранів</h2>
<p>UI, намальований для iPhone 7, має малі sprite. Кнопка меню в Dreamica мала sprite 128 × 64 пікселі й на телефоні з утричі більшою щільністю пікселів розтягувалася в кілька разів. Перемалюйте sprite у вищій роздільності: нова кнопка має 800 × 434, індикатори прогресу виросли з 200 × 26 до 800 × 104.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">128 × 64</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/button-old.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/button-old.jpg" alt="Крупний план розмитого краю кнопки" width="1120" height="608" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">800 × 434</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/button-new.webp" /><img src="https://smallwater.eu/blog/dreamica-remaster-unity-urp-glow/button-new.jpg" alt="Крупний план чіткого краю кнопки з помітною текстурою" width="1120" height="608" loading="lazy" decoding="async" /></picture></div></div><figcaption>Та сама кнопка того самого розміру на екрані. Ліворуч оригінальний sprite, праворуч новий.</figcaption></figure>
<p>Якщо UI побудований на NGUI, подумайте про переписування. Якорі NGUI обчислюються частинами протягом кількох кадрів, довгі ланцюжки якорів іноді встановлюються неправильно, а масштаб різниться між Editor, телефоном та iPad. У Dreamica виправлення не зробили це надійним, тому ми переписали UI на uGUI і TextMeshPro. Рухайтеся екран за екраном, а стару версію тримайте запущеною поруч як зразок. Масштабуйте UI за висотою відносно референсної роздільності (у нас 434 × 941 точок) і враховуйте безпечну зону екрана, тобто виріз і заокруглені кути. Після переписування повністю видаліть NGUI з проєкту, щоб у ньому не лишилося двох систем UI.</p>
<h2 id="обмеження-нестиснуті-текстури"><a class="heading-anchor" href="#обмеження-нестиснуті-текстури" aria-hidden="true" tabindex="-1">#</a>Обмеження: нестиснуті текстури</h2>
<p>Цей підхід змінює лише графіку. У Dreamica ми свідомо не стискали текстури, бо хотіли повної чіткості. Ціна в розмірі: уже оригінальна гра мала в Editor 313 МБ текстур у пам&#39;яті, а в App Store вона займає 299 МБ. За нашою оцінкою, стиснення зменшило б текстури приблизно до 40 МБ. Якщо розмір гри для вас важливий, економте спершу тут.</p>
<p>Інший приклад гри на Unity від першого рядка коду до релізу описано в статті про <a href="https://smallwater.eu/ua/blog/shmup-apple-watch-devlog/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/dreamica-remaster-unity-urp-glow/" data-ga-destination="/ua/blog/shmup-apple-watch-devlog/" data-ga-placement="inline">Space Impact Watch</a>, а продуктивності в Unity присвячена стаття про <a href="https://smallwater.eu/ua/blog/unity-dots-horde-navigation/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/dreamica-remaster-unity-urp-glow/" data-ga-destination="/ua/blog/unity-dots-horde-navigation/" data-ga-placement="inline">навігацію орди в Unity DOTS</a>. Якщо гра ще на Unity 4, не відкривайте її одразу в Unity 6; безпечний шлях описано в інструкції <a href="https://smallwater.eu/ua/blog/unity-4-to-unity-6-upgrade/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/dreamica-remaster-unity-urp-glow/" data-ga-destination="/ua/blog/unity-4-to-unity-6-upgrade/" data-ga-placement="inline">як перенести проєкт з Unity 4 на Unity 6</a>. Якщо у вас є стара гра на Unity, яка зникла з магазинів, і ви хочете її повернути, <a href="https://smallwater.eu/ua/#kontakt">напишіть нам</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Як зробити fake ragdoll для тисяч ворогів у Unity DOTS</title>
      <link>https://smallwater.eu/ua/blog/fake-ragdoll-unity-dots/</link>
      <guid isPermaLink="true">https://smallwater.eu/ua/blog/fake-ragdoll-unity-dots/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Tower Defense</category>
      <description>Як відкинути тисячі ворогів вибухом у Unity DOTS без фізики кісток: тіло як одна точка, детермінований підкид, приземлення на прохідну клітинку й обертання.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/cover.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/cover.jpg" alt="Фігури, підкинуті вибухом, летять дугами й обертаються в повітрі" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Зміст"><p class="post-toc-title">Зміст</p><ol><li><a href="#чому-не-справжніи-ragdoll" data-ga="blog_toc_clicked" data-ga-heading-id="чому-не-справжніи-ragdoll">Чому не справжній ragdoll</a></li><li><a href="#тіло-як-одна-точка" data-ga="blog_toc_clicked" data-ga-heading-id="тіло-як-одна-точка">Тіло як одна точка</a></li><li><a href="#фази-збитого-ворога" data-ga="blog_toc_clicked" data-ga-heading-id="фази-збитого-ворога">Фази збитого ворога</a></li><li><a href="#підкид-вище-а-не-далі" data-ga="blog_toc_clicked" data-ga-heading-id="підкид-вище-а-не-далі">Підкид: вище, а не далі</a></li><li><a href="#приземлення-на-прохідну-клітинку" data-ga="blog_toc_clicked" data-ga-heading-id="приземлення-на-прохідну-клітинку">Приземлення на прохідну клітинку</a><ol><li><a href="#відбиття-від-стін-у-повітрі-не-вистачає" data-ga="blog_toc_clicked" data-ga-heading-id="відбиття-від-стін-у-повітрі-не-вистачає">Відбиття від стін у повітрі не вистачає</a></li><li><a href="#передбачення-траєкторіі-під-час-підкиду" data-ga="blog_toc_clicked" data-ga-heading-id="передбачення-траєкторіі-під-час-підкиду">Передбачення траєкторії під час підкиду</a></li></ol></li><li><a href="#обертання-сплановане-за-висотою-польоту" data-ga="blog_toc_clicked" data-ga-heading-id="обертання-сплановане-за-висотою-польоту">Обертання, сплановане за висотою польоту</a></li><li><a href="#модель-на-землі" data-ga="blog_toc_clicked" data-ga-heading-id="модель-на-землі">Модель на землі</a></li><li><a href="#мертві-вороги-тіло-decal-і-пил" data-ga="blog_toc_clicked" data-ga-heading-id="мертві-вороги-тіло-decal-і-пил">Мертві вороги: тіло, decal і пил</a></li><li><a href="#пастки-в-unity-entities" data-ga="blog_toc_clicked" data-ga-heading-id="пастки-в-unity-entities">Пастки в Unity Entities</a></li><li><a href="#продуктивність" data-ga="blog_toc_clicked" data-ga-heading-id="продуктивність">Продуктивність</a></li><li><a href="#обмеження-рішення" data-ga="blog_toc_clicked" data-ga-heading-id="обмеження-рішення">Обмеження рішення</a></li></ol></nav><p>Коли вибух у грі відкидає сотні ворогів одночасно, справжній ragdoll для кожного з них надто дорогий. Fake ragdoll в Unity DOTS робиться так: усе тіло ворога ви симулюєте як одну точку з власною простою фізикою, а модель лише обертаєте навколо центру тіла. Падіння та підйом є звичайними анімаційними кліпами, кістки не симулюються взагалі.</p>
<p>Підхід показуємо на tower defense грі для ПК, яку ми робимо в Unity з ECS (<a href="https://smallwater.eu/ua/#referencie">більше в референціях</a>). Бомба або fireball там підкидає від десятків до сотень ворогів одночасно. Коли в повітрі 2 000 тіл, симуляція коштує від 0,53 до 0,59 мс на тік (тік є одним кроком ігрової симуляції фіксованої тривалості).</p>
<h2 id="чому-не-справжніи-ragdoll"><a class="heading-anchor" href="#чому-не-справжніи-ragdoll" aria-hidden="true" tabindex="-1">#</a>Чому не справжній ragdoll</h2>
<p>Справжній ragdoll означає десять і більше фізичних тіл, з&#39;єднаних суглобами, на кожного персонажа. Для тисяч персонажів на екрані це нереально.</p>
<p>Друга причина в детермінізмі: з того самого стану має виходити той самий результат. У нашій грі на цьому тримається повтор програної хвилі з контрольної точки, і ми перевіряємо це хешем стану симуляції після 1 800 тіків.</p>
<h2 id="тіло-як-одна-точка"><a class="heading-anchor" href="#тіло-як-одна-точка" aria-hidden="true" tabindex="-1">#</a>Тіло як одна точка</h2>
<p>Замість фізичного рушія використайте власну просту 2,5D фізику: позиція на сітці, висота над землею, балістичний політ, відбиття від стін і тертя. У нашій грі так рухається й бомба, а ворог, у якого влучив вибух, на мить стає таким самим тілом. Натхненням було те, як відлітають солдати в грі <a href="https://store.steampowered.com/app/1272320/Diplomacy_is_Not_an_Option/" target="_blank" rel="noopener">Diplomacy is Not an Option</a>.</p>
<p>Симуляція знає лише позицію, висоту й кут повороту. Обертання моделі в неї не записуйте. Воно належить до додаткової матриці трансформації сутності, яку пакет Entities (ECS в Unity) підтримує для нерівномірного масштабу (<a href="https://docs.unity3d.com/Packages/com.unity.entities@1.3/api/Unity.Transforms.PostTransformMatrix.html" target="_blank" rel="noopener">документація</a>).</p>
<h2 id="фази-збитого-ворога"><a class="heading-anchor" href="#фази-збитого-ворога" aria-hidden="true" tabindex="-1">#</a>Фази збитого ворога</h2>
<p>Збитий ворог проходить три фази й потім повертається в натовп:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">при влучанні:
    обчисли швидкість підкиду (сила, відстань від вибуху, випадковість із seed)
    знайди приземлення на прохідну клітинку (див. нижче)
    увімкни стан &quot;збитий&quot;, звільни місце, яке він займав біля цілі атаки
політ:        тіло летить параболою, модель обертається навколо центру тіла
приземлення:  положення за кутом (спина або живіт), коротке докочування
лежання:      чекає фіксовану кількість тіків
підйом:       кліп підйому зі спини або з живота, потім назад у натовп</code></pre>
<p>Зробіть стан «збитий» компонентом, який можна вмикати й вимикати без переміщення сутності між chunk (блоками пам&#39;яті, у яких ECS тримає сутності з однаковим набором компонентів). Навігація, розштовхування в натовпі та рішення про атаку тоді просто пропускають збитих. Як працюють навігація й розштовхування великого натовпу, описує стаття про <a href="https://smallwater.eu/ua/blog/unity-dots-horde-navigation/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/fake-ragdoll-unity-dots/" data-ga-destination="/ua/blog/unity-dots-horde-navigation/" data-ga-placement="inline">навігацію орди в Unity DOTS</a>.</p>
<h2 id="підкид-вище-а-не-далі"><a class="heading-anchor" href="#підкид-вище-а-не-далі" aria-hidden="true" tabindex="-1">#</a>Підкид: вище, а не далі</h2>
<p>Найпростіший підкид іде під кутом 45° ± 10°. За сили бомби 18 м/с і гравітації 20 м/с² вершина польоту виходить близько 4 м. Тіло пролітає низько над натовпом, і ефект у ньому губиться.</p>
<p>Більша сила не допоможе, бо разом із нею зростає й дальність. Розділіть швидкість на дві складові й множте кожну окремо: кут 60° ± 5°, горизонтальна складова × 0,85, вертикальна × 1,3. Вершина тоді близько 10 м, дальність лишається близько 15,5 м, а політ триває приблизно 2 секунди.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/launch-arcs.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/launch-arcs.png" alt="Вигляд збоку: низька пунктирна дуга й висока дуга над будинком із силуетами персонажа, що обертається під час польоту" width="2200" height="1520" loading="lazy" decoding="async" /></picture><figcaption>Пунктиром підкид під 45°, суцільною лінією підкид під 60° з окремими множниками. Будинок заввишки 5 м.</figcaption></figure>
<p>Напрямок відхиляється від вибуху на ± 15°, сила на ± 20 %, а на краю вибуху вона падає вдвічі. Не беріть випадковість із глобального генератора, а виводьте її з хешу seed рівня, ідентифікатора ворога й порядкового номера вибуху. Обробляйте запити на збиття відсортованими, бо job на кількох потоках створюють їх у довільному порядку. Два проходи тієї самої хвилі тоді дають ті самі дуги.</p>
<p>Збиття працює за принципом так або ні. Кожен ворог, якого можна збити, при влучанні щонайменше падає (швидкість обмежена знизу 2 м/с), опір змінює лише дальність. Імунітет давайте окремим прапорцем, у нашій грі його має найбільший тип ворога. Якби імунітет випливав з опору (при 0,95 швидкість виходить нижче 2 м/с), а слабке відкидання скасовувалося, ворог мав би імунітет лише випадково.</p>
<h2 id="приземлення-на-прохідну-клітинку"><a class="heading-anchor" href="#приземлення-на-прохідну-клітинку" aria-hidden="true" tabindex="-1">#</a>Приземлення на прохідну клітинку</h2>
<p>Ворог не має приземлитися в ущелину, на скелю чи в контур вежі. Прохідність визначається для кожної клітинки сітки й окремо для кожного розмірного класу ворогів.</p>
<h3 id="відбиття-від-стін-у-повітрі-не-вистачає"><a class="heading-anchor" href="#відбиття-від-стін-у-повітрі-не-вистачає" aria-hidden="true" tabindex="-1">#</a>Відбиття від стін у повітрі не вистачає</h3>
<p>Перше, що спадає на думку, це вважати непрохідні клітинки стінами, від яких летюче тіло відбивається. Біля скелі це виглядає природно, біля ущелини ні: над ущелиною повітря, але клітинка непрохідна, тож тіло відбивається від невидимого стовпа над її краєм. До того ж ніщо не гарантує, що після відбиття воно опиниться в доброму місці.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/dead-end-bounce.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/dead-end-bounce.png" alt="Вигляд збоку: тіло летить над ущелиною, посеред польоту відбивається від невидимого стовпа над її краєм і падає назад" width="2200" height="1640" loading="lazy" decoding="async" /></picture><figcaption>Відбиття від непрохідної клітинки. Тіло відбивається від повітря над краєм ущелини.</figcaption></figure>
<h3 id="передбачення-траєкторіі-під-час-підкиду"><a class="heading-anchor" href="#передбачення-траєкторіі-під-час-підкиду" aria-hidden="true" tabindex="-1">#</a>Передбачення траєкторії під час підкиду</h3>
<p>Надійніше визначити приземлення одразу під час підкиду. Просимулюйте політ наперед тим самим кроком, що й справжню фізику, і перевірте клітинку приземлення для розмірного класу ворога. Якщо вона непрохідна, пробуйте інші варіанти у фіксованому порядку:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">для кута в [0, +25, -25, +50, -50, ... , +175, -175]:
    для множника горизонтальної швидкості в [1, 0.7, 0.4, 0.1]:
        просимулюй політ
        якщо клітинка приземлення прохідна для класу ворога: візьми цей напрямок і кінець
якщо нічого не підійшло: горизонтальна швидкість = 0 (підскок на місці)</code></pre>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/fit-search.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/fit-search.png" alt="Вигляд згори на сітку з ущелиною й піднятою скелею, перекреслені відхилені приземлення й виділене перше прохідне" width="2200" height="1400" loading="lazy" decoding="async" /></picture><figcaption>Вибух, ворог, відхилені приземлення й вибране. Сірі точки є всіма кандидатами на приземлення.</figcaption></figure>
<p>Найгірший випадок становить 60 симуляцій польоту на одного ворога (15 напрямків × 4 множники). Вертикальна швидкість не змінюється, тож висота польоту лишається тією самою, скорочується лише дальність. Якщо під час польоту ворога влучить ще один вибух, запустіть передбачення знову.</p>
<figure class="post-figure post-figure-video"><video class="post-video" muted loop playsinline controls preload="none" poster="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ragdoll-loop.jpg" width="1280" height="720" data-lazy-video aria-label="Ілюстрація у 2D: двоє ворогів упали б в ущелину (хрестики), передбачення скорочує їм політ."><source src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ragdoll-loop.mp4" type="video/mp4" /></video><figcaption>Ілюстрація у 2D: двоє ворогів упали б в ущелину (хрестики), передбачення скорочує їм політ.</figcaption></figure>
<p>Щоб тіло після приземлення не ковзало й не відскакувало, задайте йому коефіцієнт відбиття від землі 0 і велике тертя. Воно лишиться там, де сказало передбачення.</p>
<h2 id="обертання-сплановане-за-висотою-польоту"><a class="heading-anchor" href="#обертання-сплановане-за-висотою-польоту" aria-hidden="true" tabindex="-1">#</a>Обертання, сплановане за висотою польоту</h2>
<p>Найпростіше крутити модель із випадковою швидкістю, наприклад від 180 до 540 °/с, а після приземлення за 0,2 с докотити її в найближче лежаче положення. Проте докочування видно, коли тіло приземляється майже вертикально. Підлаштовувати швидкість обертання щотіку так, щоб кут у момент приземлення виходив точно горизонтальним, допомагає лише частково: навіть низький кидок крутиться як сальто, а обертання ніяк не пов&#39;язане з тим, як високо летить тіло.</p>
<p>Краще спланувати обертання під час підкиду з висоти польоту:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">висота = vz² / (2 g), час польоту з vz і висоти
ціль = найближче горизонтальне положення у випадковому напрямку (чверть оберту зі стійки)
ціль += пів оберту за кожні 2,5 м висоти, не більше 3
швидкість обертання = (ціль - поточний кут) / час польоту</code></pre>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/tumble-plan.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/tumble-plan.png" alt="Дві дуги: низький кидок, за якого персонаж лише перекидається, і високий, за якого він робить три з чвертю оберти" width="2200" height="1200" loading="lazy" decoding="async" /></picture><figcaption>Низький кидок (вершина близько 2 м) дає 90°, бомба (близько 10 м) 90° і три пів оберти.</figcaption></figure>
<p>Сторона приземлення випливає з кута: обличчям до землі означає живіт, інакше спина. За нею вибирається кліп підйому. Ворог, що лежить або підводиться, при наступному влучанні зберігає поточний кут і не стрибає назад у вертикальне положення.</p>
<h2 id="модель-на-землі"><a class="heading-anchor" href="#модель-на-землі" aria-hidden="true" tabindex="-1">#</a>Модель на землі</h2>
<p>Модель обертається навколо центру тіла, але земля під її найнижчою точкою, а та змінюється з обертанням. Оцінка зсуву з двох констант і синуса кута не підходить для моделей із широко розкинутими руками в кліпі падіння: у нашій грі модель скелета (звичайний ворог) занурювалася в землю до 1,15 м, а модель некроманта до 1,73 м.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Оцінка</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ground-before.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ground-before.png" alt="Шість поворотів персонажа, частини під землею червоні" width="2200" height="840" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Виміряно</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ground-after.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/ground-after.png" alt="Шість поворотів персонажа, кожен торкається землі найнижчою точкою" width="2200" height="840" loading="lazy" decoding="async" /></picture></div></div><figcaption>Ті самі повороти зі зсувом із констант і з виміряної таблиці.</figcaption></figure>
<p>Надійніше найнижчу точку виміряти. Під час запікання (bake) анімацій обчисліть для кліпу падіння найнижчу точку тіла для 72 поворотів із кроком 5°, а під час гри інтерполюйте з таблиці. Найглибше занурення тоді зменшується до 4 мм. Проте одного мінімуму по всіх кадрах кліпу не досить: тіло після приземлення висіло б на висоті від 0,15 до 0,28 м над землею, доки кліп підйому не стягне його вниз. Тому таблиця має мати розмір кадри × повороти.</p>
<p>Повільне сплановане обертання виявляє ще один ефект. Якщо класти модель найнижчою точкою на висоту польоту, центр тіла під час переходу зі стійки в горизонталь опускається на різницю між половиною зросту й половиною товщини персонажа. Тіло тоді на початку польоту піднімається лише повільно й тільки потім прискорюється. Тому підніміть модель на різницю між половиною розміру в стійці та за поточного кута, але не більше ніж на висоту польоту. Тоді центр тіла йде параболою.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/flight-lift.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/flight-lift.png" alt="Графік висоти центру тіла в часі: пунктирна ідеальна парабола, червона крива з провалом і синя, що повторює параболу" width="2200" height="1040" loading="lazy" decoding="async" /></picture><figcaption>Висота центру тіла під час польоту: без підйому (червона) і з підйомом (синя).</figcaption></figure>
<h2 id="мертві-вороги-тіло-decal-і-пил"><a class="heading-anchor" href="#мертві-вороги-тіло-decal-і-пил" aria-hidden="true" tabindex="-1">#</a>Мертві вороги: тіло, decal і пил</h2>
<p>Ворог, убитий вибухом, теж має відлетіти. Створіть для нього летюче тіло: копію сутності без ігрових компонентів (здоров&#39;я, нагорода, ціль). Тіло пролітає, падає, якийсь час лежить і зникає. На його місці лишається decal трупа (текстура, спроєктована на землю), як у Warcraft 3, і хмарка пилу. У нашій грі ліміти становлять 1 000 летючих тіл і 1 500 decal.</p>
<p>Decal є виглядом згори на кадр із кліпу падіння, поверненим так само, як лежаче тіло, тож заміни тіла на decal не видно. Перший кадр кліпу підйому не підходить, бо в ньому персонаж уже сідає зі спини. Об&#39;єднуйте хмарки пилу в межах клітинки 1,5 м. Бомба в натовп тоді створює кілька більших хмар замість сотень дрібних.</p>
<h2 id="пастки-в-unity-entities"><a class="heading-anchor" href="#пастки-в-unity-entities" aria-hidden="true" tabindex="-1">#</a>Пастки в Unity Entities</h2>
<ul>
<li><strong>Матриця з baker зникає.</strong> Якщо додати додаткову матрицю під час bake, система трансформацій прибирає її за рівномірного масштабу, і збиті вороги не рухаються. Тому під час bake запросіть нерівномірний масштаб. Тоді, однак, увесь масштаб prefab переходить у ту саму матрицю, тож не перезаписуйте її самим обертанням. Інакше збитий ворог зі збільшенням 2× має половинний розмір навіть після підйому. Зберігайте масштаб окремо й складайте матрицю з масштабу та обертання.</li>
<li><strong>Висота кореня під час імпорту кліпів.</strong> У humanoid кліпах вертикальний рух кореня (root) відкидається, і персонажі під час підйому провалюються під підлогу. Запечіть висоту кореня в позу. Опцію прив&#39;язати позу до стоп не використовуйте, вона зсуває все тіло на метр униз.</li>
<li><strong>Матриця в замкненому вигляді.</strong> Зсув, обертання й зворотний зсув як три множення матриць у job помітно повільніші за вручну написану формулу підсумкової матриці.</li>
<li><strong>Burst зі старою версією.</strong> Після зміни полів компонента, який читає Burst job, редактор може падати або видавати нісенітниці. Допомагає перезапуск редактора.</li>
</ul>
<h2 id="продуктивність"><a class="heading-anchor" href="#продуктивність" aria-hidden="true" tabindex="-1">#</a>Продуктивність</h2>
<p>Виміряно в Unity Editor на M3 Pro, 10 000 скелетів у бенчмарк-сцені, сила підкиду 18. Часи є сумою процесорного часу Burst job по всіх потоках, а не часом кадру. Графік і таблиця без передбачення траєкторії й таблиць приземлення.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/fake-ragdoll-unity-dots/bench.webp" /><img src="https://smallwater.eu/blog/fake-ragdoll-unity-dots/bench.png" alt="Графік часу на тік залежно від кількості збитих ворогів для симуляції та підготовки візуалу, з цільовою лінією 0,5 мс" width="2200" height="1120" loading="lazy" decoding="async" /></picture><figcaption>Симуляція та підготовка візуалу без передбачення траєкторії. Ціль 0,5 мс для 2 000 збитих.</figcaption></figure>
<div class="table-wrap"><table>
<thead>
<tr>
<th>збитих</th>
<th>симуляція (мс/тік)</th>
<th>підготовка візуалу (мс)</th>
</tr>
</thead>
<tbody><tr>
<td>500</td>
<td>від 0,28 до 0,30</td>
<td>0,15</td>
</tr>
<tr>
<td>2 000</td>
<td>0,53</td>
<td>від 0,30 до 0,55</td>
</tr>
<tr>
<td>5 000</td>
<td>від 0,74 до 0,75</td>
<td>від 0,49 до 0,91</td>
</tr>
<tr>
<td>10 000</td>
<td>від 0,96 до 1,01</td>
<td>від 0,68 до 1,24</td>
</tr>
</tbody></table>
</div>
<ul>
<li>Симуляція зростає лінійно, приблизно на 0,25 мс на тисячу збитих. З передбаченням траєкторії й таблицями приземлення це від 0,58 до 0,59 мс для 2 000 збитих, тобто трохи вище цілі 0,5 мс. Тому розумний ліміт становить 3 000 одночасно збитих, це близько 0,8 мс.</li>
<li>Вибух у групу з 359 ворогів біля стіни коштує в системі запитів на збиття від 1,3 до 1,9 мс за один кадр. Більшу частину займає передбачення траєкторії.</li>
<li>FPS з редактора порівнювати не можна: два проходи тієї самої конфігурації відрізнялися аж на 80 FPS.</li>
</ul>
<h2 id="обмеження-рішення"><a class="heading-anchor" href="#обмеження-рішення" aria-hidden="true" tabindex="-1">#</a>Обмеження рішення</h2>
<p>Підготовка візуалу в іншій тестовій сцені зросла аж до 1,3 мс, імовірно через читання таблиці приземлення для кожного тіла. Це перший кандидат на оптимізацію. FPS у білді й навантаження під час масового вбивства 1 000 ворогів одночасно (ціль щонайбільше 1,5 мс додатково на кадр) ми ще не вимірювали.</p>
<p>Якщо ви працюєте над схожою симуляцією натовпу чи ефектів у Unity і потребуєте допомоги, <a href="https://smallwater.eu/ua/#kontakt">напишіть нам</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Як створити shmup для Apple Watch</title>
      <link>https://smallwater.eu/ua/blog/shmup-apple-watch-devlog/</link>
      <guid isPermaLink="true">https://smallwater.eu/ua/blog/shmup-apple-watch-devlog/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Space Impact Watch</category>
      <description>Посібник зі створення shmup для Apple Watch: віртуальна роздільна здатність, Digital Crown, колізії прямокутниками, pooling, рівні як таблиці часу, генератор.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/cover.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/cover.jpg" alt="Піксельна сцена на зеленому дисплеї: корабель ліворуч стріляє по кометах, ракетах і босу праворуч" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Зміст"><p class="post-toc-title">Зміст</p><ol><li><a href="#фіксована-віртуальна-роздільна-здатність-84-48" data-ga="blog_toc_clicked" data-ga-heading-id="фіксована-віртуальна-роздільна-здатність-84-48">Фіксована віртуальна роздільна здатність 84 × 48</a></li><li><a href="#керування-коронкою-і-автоматичнии-вогонь" data-ga="blog_toc_clicked" data-ga-heading-id="керування-коронкою-і-автоматичнии-вогонь">Керування коронкою і автоматичний вогонь</a></li><li><a href="#game-loop-в-одніи-сцені" data-ga="blog_toc_clicked" data-ga-heading-id="game-loop-в-одніи-сцені">Game loop в одній сцені</a></li><li><a href="#колізіі-прямокутниками-замість-фізичного-рушія" data-ga="blog_toc_clicked" data-ga-heading-id="колізіі-прямокутниками-замість-фізичного-рушія">Колізії прямокутниками замість фізичного рушія</a></li><li><a href="#pooling-лише-там-де-він-окупається" data-ga="blog_toc_clicked" data-ga-heading-id="pooling-лише-там-де-він-окупається">Pooling лише там, де він окупається</a></li><li><a href="#рівні-як-таблиці-часу" data-ga="blog_toc_clicked" data-ga-heading-id="рівні-як-таблиці-часу">Рівні як таблиці часу</a></li><li><a href="#нескінченнии-режим-з-одного-псевдовипадкового-числа" data-ga="blog_toc_clicked" data-ga-heading-id="нескінченнии-режим-з-одного-псевдовипадкового-числа">Нескінченний режим з одного псевдовипадкового числа</a></li><li><a href="#обмеження-цього-підходу" data-ga="blog_toc_clicked" data-ga-heading-id="обмеження-цього-підходу">Обмеження цього підходу</a></li></ol></nav><p>Shmup (shoot &#39;em up, стрілялка, у якій корабель летить проти хвиль ворогів) на Apple Watch стикається з трьома проблемами: малий дисплей, палець, що закриває половину гри, і годинники різних розмірів. Їх вирішують три рішення: уся гра працює на фіксованій сітці 84 × 48 точок, колізії перевіряються вручну прямокутниками, а рівні є таблицями з часом, а не кодом. Корабель керується коронкою Digital Crown і стріляє сам.</p>
<p>Приклади взято з гри <a href="https://smallwater.eu/space-impact-watch">Space Impact Watch</a>, ремейку стрілялки Space Impact з телефона Nokia 3310 2000 року. Гра працює на годиннику, а також на iPhone та iPad. Вона побудована на SpriteKit, 2D-фреймворку для ігор від Apple, але принципи підходять для будь-якого shmup на малому екрані.</p>
<h2 id="фіксована-віртуальна-роздільна-здатність-84-48"><a class="heading-anchor" href="#фіксована-віртуальна-роздільна-здатність-84-48" aria-hidden="true" tabindex="-1">#</a>Фіксована віртуальна роздільна здатність 84 × 48</h2>
<p>Спочатку вирішіть, у яких одиницях рахуватиме гра. Якщо взяти точки екрана, світ на кожному розмірі годинника буде різним. Краще вибрати малу віртуальну роздільну здатність і все рахувати в ній. У нашій грі це 84 × 48, точно як <a href="https://www.adafruit.com/products/338" target="_blank" rel="noopener">дисплей Nokia 3310</a>. Позиції, швидкості, розміри sprite і траєкторії ворогів задані в цих одиницях. На старті сцени один раз обчислюється, скільки точок екрана має один віртуальний піксель (ширина сцени поділена на 84), і все просто множиться на це число.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/grid.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/grid.png" alt="Ігрова сцена з видимою сіткою 84 × 48 віртуальних пікселів" width="1008" height="576" loading="lazy" decoding="async" /></picture><figcaption>Рівень на сітці 84 × 48. Кожен квадратик є одним віртуальним пікселем, який годинник малює як блок із кількох справжніх точок.</figcaption></figure>
<p>Що ви отримуєте:</p>
<ul>
<li><strong>Однаковий вигляд на кожному пристрої.</strong> Гра виглядає однаково на годиннику будь-якого розміру і на iPad, змінюється лише розмір блока.</li>
<li><strong>Level design у малих цілих числах.</strong> Ворог летить на висоті 13, ракета повертає в точці x = 20.</li>
<li><strong>Просту графіку.</strong> Sprite одноколірні й намальовані в десятикратному розмірі, тож один віртуальний піксель у вихідному зображенні є блоком 10 × 10. Колір вони отримують уже в грі: на світлих рівнях чорні на зеленому тлі, на темних навпаки.</li>
</ul>
<h2 id="керування-коронкою-і-автоматичнии-вогонь"><a class="heading-anchor" href="#керування-коронкою-і-автоматичнии-вогонь" aria-hidden="true" tabindex="-1">#</a>Керування коронкою і автоматичний вогонь</h2>
<p>На годиннику палець закриває половину гри, тому корабель не керується дотиком. Рух забезпечує Digital Crown: поворот коронки зсуває корабель угору або вниз. Додайте налаштування чутливості й можливість інвертувати напрямок.</p>
<p>Стрільба автоматична, у нашій грі кожні 0,4 секунди, з четвертого рівня кожні 0,3. Дисплей потрібен лише для одного жесту: свайпом праворуч гравець застосовує один із трьох видів спецзброї.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/weapons.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/weapons.png" alt="Три види спецзброї: вертикальна лінія, керована ракета і лазер, що влучає в боса" width="1104" height="240" loading="lazy" decoding="async" /></picture><figcaption>Три види спецзброї: лінія на всю висоту поля, керована ракета і лазер.</figcaption></figure>
<p>На годиннику вібрація при влучанні так само важлива, як анімація, маленький вибух на дисплеї легко пропустити. Викликайте тактильний відгук через один інтерфейс (влучання, знищення ворога, перемога над босом), який кожна платформа реалізує по-своєму: годинник вібрацією, iPhone власним способом. Версія для телефона не має коронки, натомість має кнопки на дисплеї з тими самими налаштуваннями чутливості й напрямку.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/watch-2021.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/watch-2021.png" alt="Знімок гри в симуляторі Apple Watch, корабель проти боса" width="368" height="448" loading="lazy" decoding="async" /></picture><figcaption>Гра в симуляторі Apple Watch: корабель проти боса.</figcaption></figure>
<h2 id="game-loop-в-одніи-сцені"><a class="heading-anchor" href="#game-loop-в-одніи-сцені" aria-hidden="true" tabindex="-1">#</a>Game loop в одній сцені</h2>
<p>Уся гра може бути однією сценою з одним циклом:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">щокадру:
    перемісти всі об&#x27;єкти
    перевір колізії
    залежно від часу на рівні додай нових ворогів</code></pre>
<p>Додавайте частини в такому порядку, щоб якомога раніше їх тестувати:</p>
<ul>
<li><strong>Стрільба і рух гравця.</strong> Без них нічого не протестуєш.</li>
<li><strong>Вороги і колізії.</strong> Першому ворогу достатньо летіти прямо ліворуч і гинути від одного влучання.</li>
<li><strong>Вібрація і анімації влучання.</strong> Без них на малому дисплеї гравець не знає, що влучив.</li>
<li><strong>Тло і HUD.</strong> Життя, рахунок, кількість спецзброї. Якщо HUD на якомусь рівні заважає, перенесіть його вниз.</li>
</ul>
<p>У результаті маєте ігровий цикл із життями, рахунком, вибухами, тлом і respawn гравця. Якщо тримати ігрову логіку окремо від рендерингу та введення, її можуть спільно використовувати версії для годинника й телефона.</p>
<h2 id="колізіі-прямокутниками-замість-фізичного-рушія"><a class="heading-anchor" href="#колізіі-прямокутниками-замість-фізичного-рушія" aria-hidden="true" tabindex="-1">#</a>Колізії прямокутниками замість фізичного рушія</h2>
<p>SpriteKit має фізичний рушій, і його кортить використати й для колізій. Кожен об&#39;єкт отримує фізичне тіло з контуру свого sprite, точне виявлення колізій і категорію (гравець, ворог, куля, спецзброя), а рушій сам повідомляє пари, що торкнулися. Apple у <a href="https://developer.apple.com/documentation/spritekit/getting-started-with-physics-bodies" target="_blank" rel="noopener">документації про фізичні тіла</a> зазначає, що тіла з альфа-каналу текстури найточніші й водночас найдорожчі для обчислень. У нашій грі ми це спробували й повернулися до ручної перевірки.</p>
<p>Ручна перевірка є однією функцією, яка по черзі проходить пари груп і порівнює прямокутники:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">для кожної пари груп:
    (гравець і вороги, гравець і кулі, кулі й вороги, кулі й кулі)
    для кожного об&#x27;єкта A з першої групи та об&#x27;єкта B з другої:
        якщо прямокутник A перекривається з прямокутником B:
            обробити влучання</code></pre>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/hitbox.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/hitbox.png" alt="Дві пари sprite із позначеними прямокутниками колізій, ліворуч перекриваються лише кути прямокутників" width="960" height="352" loading="lazy" decoding="async" /></picture><figcaption>Ліворуч: прямокутники перекриваються, пікселі ні, і гра зараховує це як влучання. Праворуч: справжнє влучання.</figcaption></figure>
<p>Переваги: порядок перевірок під вашим контролем, а все правило «що стається при влучанні» зібране в одному місці. Неточність мала, бо на сітці 84 × 48 sprite такі дрібні, що прямокутник і справжній контур відрізняються лише на кілька віртуальних пікселів.</p>
<h2 id="pooling-лише-там-де-він-окупається"><a class="heading-anchor" href="#pooling-лише-там-де-він-окупається" aria-hidden="true" tabindex="-1">#</a>Pooling лише там, де він окупається</h2>
<p>Pooling означає, що об&#39;єкт після знищення ви не видаляєте, а ховаєте й наступного разу використовуєте знову. Кортить застосувати pooling до всього, разом із вибухами та power-up. Але це має ціну: кожна колізія і кожне оновлення спершу мусить перевірити, чи об&#39;єкт узагалі живий. Досить однієї забутої перевірки, і прихований ворог уб&#39;є гравця або прихована куля зіб&#39;є ракету. Коли ми в нашій грі прибирали pooling з усього, видаляли переважно саме ці перевірки.</p>
<p>Просте правило: застосовуйте pooling до того, чого багато і що завжди створюється однаково. Гравець на старті створює 21 кулю і далі лише використовує їх повторно. Вороги та їхні кулі теж використовуються повторно, а вибухи й power-up створюються і зникають звичайним способом.</p>
<h2 id="рівні-як-таблиці-часу"><a class="heading-anchor" href="#рівні-як-таблиці-часу" aria-hidden="true" tabindex="-1">#</a>Рівні як таблиці часу</h2>
<p>Рівень не мусить бути кодом, досить списку записів. Кожен запис каже, що з&#39;явиться, на якій секунді, на якій висоті і як рухатиметься. Рух є списком точок, через які ворог летить зі сталою швидкістю. За потреби додаються моменти пострілів, ривок боса або генератор менших ворогів.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">щокадру:
    час = зараз - початок рівня - час на паузі
    для кожного запису в таблиці рівня:
        якщо запис ще не використано і час &gt; запис.час:
            створи об&#x27;єкт за записом
            познач запис як використаний</code></pre>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/level1.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/level1.png" alt="Часова шкала першого рівня: зліва направо секунди, висота іконок відповідає висоті, на якій з&#39;являється ворог" width="1004" height="120" loading="lazy" decoding="async" /></picture><figcaption>Перший рівень: 39 записів за 67 секунд. Комети, потім хвилі ракет, power-up (червоні) і бос наприкінці.</figcaption></figure>
<p>Такий рівень легко налаштовувати: зсунути хвилю на пів секунди означає змінити одне число. На часовій шкалі видно й ритм, якого треба досягти: коротка хвиля, пауза, інший тип ворога, power-up перед складнішою ділянкою.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/path.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/path.png" alt="Ракета летить зигзагом через ігрове поле, точки зламу траєкторії позначені" width="1008" height="576" loading="lazy" decoding="async" /></picture><figcaption>Траєкторія ракети з першого рівня: шість точок, через які вона летить зі сталою швидкістю.</figcaption></figure>
<p>Недолік відчувається при більших змінах. Найщільніший рівень нашої гри має 71 рядок, і таку таблицю без редактора читати важко. Вигідніше записувати рівні як хвилі (група ворогів з одним шаблоном руху), а не як окремі кораблі.</p>
<h2 id="нескінченнии-режим-з-одного-псевдовипадкового-числа"><a class="heading-anchor" href="#нескінченнии-режим-з-одного-псевдовипадкового-числа" aria-hidden="true" tabindex="-1">#</a>Нескінченний режим з одного псевдовипадкового числа</h2>
<p>Для таблиці лідерів (у нашій грі Game Center) потрібен режим, який не закінчується. У ньому ручну таблицю замінює генератор, що створює її під час гри. Він генерує 20-значне псевдовипадкове число, і окремі пари цифр визначають одну хвилю:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">число = наступне псевдовипадкове 20-значне число
тип = power-up, якщо цифри 4-5 &gt; 90, інакше ворог
sprite = цифри 1-2 за модулем кількості видів ворогів
пауза перед хвилею = цифра 3 / 2 секунди (щонайменше 1)
висота = цифра 7, перерахована на висоту поля
шаблон руху = цифри 10-11 за модулем 10
кораблів у хвилі = цифра 17 / 2 + 1
швидкість = цифра 13 × (1 + номер хвилі / 10), у межах від 20 до 50
кожна тридцята хвиля = бос, генерація стоїть, поки бос живий</code></pre>
<p>Залиште seed (початкове значення) генератора в грі фіксованим. Тоді кожен гравець отримає однакову послідовність хвиль, і результати в таблиці лідерів можна порівнювати.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/patterns.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/patterns.png" alt="Десять маленьких ігрових полів, на кожному один шаблон руху ворога пунктирною лінією" width="912" height="228" loading="lazy" decoding="async" /></picture><figcaption>Десять шаблонів руху з генератора. Точна форма залежить від висоти, на якій з&#39;являється хвиля.</figcaption></figure>
<p>Як розвивається такий режим, показує симуляція генератора поза грою (300 хвиль, той самий seed, що й у грі). Перший бос з&#39;являється після 29 хвиль, тобто після 74 ворогів і 3 power-up, приблизно на 130-й секунді. Перші 30 хвиль летять із мінімальною швидкістю 20, бо одна цифра ще менша за нижню межу. Далі середня швидкість зростає до 28,5, 37,5 і 41, а від 120-ї хвилі більшість ворогів летить на максимумі. Power-up випадає приблизно кожну одинадцяту хвилю.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/shmup-apple-watch-devlog/ranked.webp" /><img src="https://smallwater.eu/blog/shmup-apple-watch-devlog/ranked.png" alt="Часова шкала перших 135 секунд нескінченного режиму з іконками ворогів і босом наприкінці" width="1104" height="120" loading="lazy" decoding="async" /></picture><figcaption>Перші 135 секунд нескінченного режиму за симуляцією генератора. Хвилі довші й нерегулярніші, ніж у рівні, написаному вручну.</figcaption></figure>
<p>Слабке місце: генератор не знає ритму. Ручний рівень чергує тиск і відпочинок, генератор ні. Частково це компенсують пауза під час боса і зростання швидкості, але рівні, написані вручну, грати цікавіше.</p>
<h2 id="обмеження-цього-підходу"><a class="heading-anchor" href="#обмеження-цього-підходу" aria-hidden="true" tabindex="-1">#</a>Обмеження цього підходу</h2>
<ul>
<li><strong>Немає автоматичних тестів.</strong> Усе перевіряється вручну в симуляторі й на годиннику.</li>
<li><strong>Глобальний стан.</strong> Більша частина стану гри є глобальними змінними однієї сцени. Для гри лише на годинник це зручно, а при додаванні версії для iPhone вже ні.</li>
<li><strong>Рівні без редактора.</strong> Таблицю часу легко налаштовувати малими кроками, але для більших змін їй бракує редактора.</li>
</ul>
<p>Якщо ви робите гру для годинника чи іншого маленького екрана і вам потрібна допомога, <a href="https://smallwater.eu/ua/#kontakt">напишіть нам</a>. Як ми генеруємо головоломки в іншому нашому застосунку, ми описали в статті про <a href="https://smallwater.eu/ua/blog/sudoku-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/shmup-apple-watch-devlog/" data-ga-destination="/ua/blog/sudoku-generator/" data-ga-placement="inline">генератор судоку</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Як згенерувати судоку з єдиним розв&apos;язком</title>
      <link>https://smallwater.eu/ua/blog/sudoku-generator/</link>
      <guid isPermaLink="true">https://smallwater.eu/ua/blog/sudoku-generator/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Кросворди та головоломки</category>
      <description>Генератор судоку крок за кроком: швидкий solver з бітовими масками, випадкова повна сітка, видалення клітинок з перевіркою єдиності та складність.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/cover.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/cover.jpg" alt="Частково розв&#39;язане судоку з маленькими цифрами-кандидатами і виділеною клітинкою з одним кандидатом" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Зміст"><p class="post-toc-title">Зміст</p><ol><li><a href="#чому-рівно-один-розв-язок" data-ga="blog_toc_clicked" data-ga-heading-id="чому-рівно-один-розв-язок">Чому рівно один розв язок</a></li><li><a href="#крок-1-напишіть-швидкии-solver" data-ga="blog_toc_clicked" data-ga-heading-id="крок-1-напишіть-швидкии-solver">Крок 1: Напишіть швидкий solver</a></li><li><a href="#крок-2-заповніть-усю-сітку" data-ga="blog_toc_clicked" data-ga-heading-id="крок-2-заповніть-усю-сітку">Крок 2: Заповніть усю сітку</a></li><li><a href="#чому-не-досить-видалити-випадкові-клітинки" data-ga="blog_toc_clicked" data-ga-heading-id="чому-не-досить-видалити-випадкові-клітинки">Чому не досить видалити випадкові клітинки</a></li><li><a href="#крок-3-видаляите-з-перевіркою-єдиності" data-ga="blog_toc_clicked" data-ga-heading-id="крок-3-видаляите-з-перевіркою-єдиності">Крок 3: Видаляйте з перевіркою єдиності</a></li><li><a href="#крок-4-задаите-складність-кількістю-порожніх-клітинок" data-ga="blog_toc_clicked" data-ga-heading-id="крок-4-задаите-складність-кількістю-порожніх-клітинок">Крок 4: Задайте складність кількістю порожніх клітинок</a></li><li><a href="#швидкість-і-надіиність-генератора" data-ga="blog_toc_clicked" data-ga-heading-id="швидкість-і-надіиність-генератора">Швидкість і надійність генератора</a></li><li><a href="#коли-кількості-порожніх-клітинок-не-досить" data-ga="blog_toc_clicked" data-ga-heading-id="коли-кількості-порожніх-клітинок-не-досить">Коли кількості порожніх клітинок не досить</a></li></ol></nav><p>Судоку з єдиним розв&#39;язком генерується у два кроки. Спершу повністю заповніть порожню сітку 9×9 за допомогою backtracking, пробуючи цифри у випадковому порядку. Потім по одній і у випадковому порядку видаляйте клітинки й після кожного видалення перевіряйте за допомогою solver, чи має головоломка досі рівно один розв&#39;язок. Якщо ні, поверніть цифру назад. На звичайному процесорі це займає в медіані менше мілісекунди.</p>
<p>Цей метод підходить для будь-якої гри чи застосунку, яким потрібна необмежена кількість нових головоломок без ручної підготовки. Приклад із практики: судоку в нашому застосунку з кросвордами та головоломками (<a href="https://smallwater.eu/crosswords-ios">App Store</a>, <a href="https://smallwater.eu/crosswords-android">Google Play</a>).</p>
<h2 id="чому-рівно-один-розв-язок"><a class="heading-anchor" href="#чому-рівно-один-розв-язок" aria-hidden="true" tabindex="-1">#</a>Чому рівно один розв&#39;язок</h2>
<p>Правила знають усі: у кожному рядку, стовпці та квадраті 3×3 кожна цифра від 1 до 9 трапляється рівно один раз. Рідше кажуть, що хороша головоломка має <strong>єдиний</strong> розв&#39;язок. Лише тоді її можна розв&#39;язати чистою логікою, без вгадування.</p>
<p>Це має й практичний наслідок. Гра зазвичай зберігає один розв&#39;язок і за ним перевіряє результат та дає підказки. Якщо розв&#39;язків два, гравець може дійти до іншого, такого ж правильного, і гра його не зарахує.</p>
<h2 id="крок-1-напишіть-швидкии-solver"><a class="heading-anchor" href="#крок-1-напишіть-швидкии-solver" aria-hidden="true" tabindex="-1">#</a>Крок 1: Напишіть швидкий solver</h2>
<p>Solver є програмою, яка розв&#39;язує судоку. Він працює методом backtracking (перебір із поверненням): ставить у клітинку одну з можливих цифр, переходить до наступної клітинки, а коли натрапляє на суперечність, повертається й пробує іншу цифру. Він знадобиться двічі: щоб заповнити порожню сітку і щоб перевіряти єдиність. Перевірка запускається після кожної видаленої клітинки, тобто 40-70 разів на одну головоломку, тож вона має бути швидкою. Значення мають дві речі:</p>
<ul>
<li><strong>Бітові маски.</strong> Для кожного рядка, стовпця й квадрата зберігайте 9-бітне число з уже використаними цифрами. Кандидати клітинки, тобто цифри, які ще можуть у неї потрапити, тоді обчислюються однією операцією: усі цифри, крім тих, що вже є в її рядку, стовпці чи квадраті. Кількість кандидатів дорівнює кількості одиничних бітів, і її можна заздалегідь обчислити в таблицю з 512 значень.</li>
<li><strong>Спершу клітинка з найменшою кількістю кандидатів.</strong> Беріть не першу порожню клітинку зліва, а ту, де найменше варіантів (у літературі евристика MRV, minimum remaining values). Клітинку з одним кандидатом заповнюйте одразу, клітинка без кандидатів означає мертву гілку, і ви повертаєтеся назад.</li>
</ul>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/candidates.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/candidates.png" alt="Частково заповнена сітка з маленькими цифрами-кандидатами в порожніх клітинках, одна клітинка з єдиним кандидатом виділена" width="1024" height="1034" loading="lazy" decoding="async" /></picture><figcaption>Кандидати в порожніх клітинках. Виділена клітинка має одного кандидата, тому solver заповнює її першою.</figcaption></figure>
<p>На складних головоломках перевірка єдиності з цим правилом потребує в медіані 311 кроків. Якщо solver бере клітинки по черзі зліва направо, в медіані 28 579 кроків, а в найгірших 5 % випадків понад 300 000. Різниця приблизно у 90 разів.</p>
<h2 id="крок-2-заповніть-усю-сітку"><a class="heading-anchor" href="#крок-2-заповніть-усю-сітку" aria-hidden="true" tabindex="-1">#</a>Крок 2: Заповніть усю сітку</h2>
<p>Повну сітку дає той самий solver, запущений на порожній сітці. Єдина відмінність: кандидатів клітинки ви пробуєте у випадковому порядку. Перший знайдений розв&#39;язок і є випадковою, правильною, повністю заповненою сіткою.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/solved.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/solved.png" alt="Повністю заповнена сітка судоку 9×9" width="1024" height="1034" loading="lazy" decoding="async" /></picture><figcaption>Випадкова повна сітка. З неї вийде головоломка.</figcaption></figure>
<h2 id="чому-не-досить-видалити-випадкові-клітинки"><a class="heading-anchor" href="#чому-не-досить-видалити-випадкові-клітинки" aria-hidden="true" tabindex="-1">#</a>Чому не досить видалити випадкові клітинки</h2>
<p>Найпростіша ідея: видалити з повної сітки N випадкових клітинок, наприклад 40, 50 або 55 залежно від складності. Головоломка виглядає нормально, тільки часто має не один розв&#39;язок.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/ambiguous.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/ambiguous.png" alt="Головоломка судоку, де чотири клітинки в кутах прямокутника мають дві можливі пари цифр" width="1024" height="1034" loading="lazy" decoding="async" /></picture><figcaption>Головоломка з випадкового видалення. У виділених клітинках підходять обидві пари цифр, тому розв&#39;язків два.</figcaption></figure>
<p>На зображенні типовий випадок. Чотири клітинки в кутах прямокутника, що зачіпає два квадрати, можна заповнити двома способами, і решта сітки цього не вирішує. Любителі судоку знають цей візерунок як deadly pattern. На 1 000 головоломках для кожної складності результат такий:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Складність</th>
<th align="right">Видалено клітинок</th>
<th align="right">Головоломок з кількома розв&#39;язками</th>
</tr>
</thead>
<tbody><tr>
<td>Легка</td>
<td align="right">40</td>
<td align="right">59 %</td>
</tr>
<tr>
<td>Середня</td>
<td align="right">50</td>
<td align="right">96 %</td>
</tr>
<tr>
<td>Складна</td>
<td align="right">55</td>
<td align="right">100 %</td>
</tr>
</tbody></table>
</div>
<p>Складна головоломка з випадкового видалення має в середньому сотні розв&#39;язків. Тому перевірка єдиності після кожного видалення обов&#39;язкова.</p>
<h2 id="крок-3-видаляите-з-перевіркою-єдиності"><a class="heading-anchor" href="#крок-3-видаляите-з-перевіркою-єдиності" aria-hidden="true" tabindex="-1">#</a>Крок 3: Видаляйте з перевіркою єдиності</h2>
<p>Пройдіть усі 81 клітинку у випадковому порядку. Спробуйте спорожнити кожну й порахуйте розв&#39;язки за допомогою solver, але зупиніть підрахунок на двох. Більше знати не треба: один розв&#39;язок означає, що клітинка може лишитися порожньою, два означають, що її треба повернути.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">головоломка = копія повної сітки
порожніх = 0
для кожної клітинки у випадковому порядку:
    якщо порожніх == ціль: кінець
    цифра = головоломка[клітинка]
    головоломка[клітинка] = порожньо
    якщо кількість_розв&#x27;язків(головоломка, не більше 2) == 1:
        порожніх = порожніх + 1
    інакше:
        головоломка[клітинка] = цифра</code></pre>
<p>Клітинку, яку ви раз повернули, більше не пробуйте. Подальше видалення лише послаблює головоломку, тож клітинка, яку не вдалося спорожнити зараз, не спорожниться й пізніше.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/dig.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/dig.png" alt="Частково готова складна головоломка з 37 порожніми клітинками й однією виділеною цифрою, яку довелося повернути" width="1024" height="1034" loading="lazy" decoding="async" /></picture><figcaption>Після 37 видалених клітинок. Виділену цифру видалити не вдалося, головоломка мала б два розв&#39;язки, тому її повернуто.</figcaption></figure>
<h2 id="крок-4-задаите-складність-кількістю-порожніх-клітинок"><a class="heading-anchor" href="#крок-4-задаите-складність-кількістю-порожніх-клітинок" aria-hidden="true" tabindex="-1">#</a>Крок 4: Задайте складність кількістю порожніх клітинок</h2>
<p>Найпростішою мірою складності є кількість видалених клітинок. Три рівні, які ми використовуємо в нашій грі:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Складність</th>
<th align="right">Порожніх клітинок</th>
<th align="right">Залишається цифр</th>
<th align="right">У середньому повернено клітинок</th>
</tr>
</thead>
<tbody><tr>
<td>Легка</td>
<td align="right">40</td>
<td align="right">41</td>
<td align="right">0,9</td>
</tr>
<tr>
<td>Середня</td>
<td align="right">50</td>
<td align="right">31</td>
<td align="right">4,5</td>
</tr>
<tr>
<td>Складна</td>
<td align="right">55</td>
<td align="right">26</td>
<td align="right">12,7</td>
</tr>
</tbody></table>
</div>
<p>Що менше цифр залишається, то частіше наступне видалення зламало б єдиність і клітинку доводиться повертати. На складному рівні один прохід іноді зупиняється раніше, ніж досягне 55 порожніх клітинок. Тоді згенеруйте нову повну сітку й спробуйте знову. У вимірюванні на 1 000 складних головоломках першого проходу вистачило в 97,6 % випадків, і жодного разу не знадобилося більше 3 спроб.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/sudoku-generator/final.webp" /><img src="https://smallwater.eu/blog/sudoku-generator/final.png" alt="Готова складна головоломка судоку з 26 цифрами" width="1024" height="1034" loading="lazy" decoding="async" /></picture><figcaption>Готова складна головоломка: 26 цифр і єдиний розв&#39;язок.</figcaption></figure>
<h2 id="швидкість-і-надіиність-генератора"><a class="heading-anchor" href="#швидкість-і-надіиність-генератора" aria-hidden="true" tabindex="-1">#</a>Швидкість і надійність генератора</h2>
<p>Ми запустили генератор на 1 000 головоломках для кожної складності (Node.js 22 на серверному процесорі, телефон буде повільнішим):</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Складність</th>
<th align="right">Медіана часу</th>
<th align="right">95 % головоломок до</th>
<th align="right">Найдовше</th>
<th align="right">Головоломок з кількома розв&#39;язками</th>
</tr>
</thead>
<tbody><tr>
<td>Легка</td>
<td align="right">0,13 мс</td>
<td align="right">0,29 мс</td>
<td align="right">3,5 мс</td>
<td align="right">0</td>
</tr>
<tr>
<td>Середня</td>
<td align="right">0,21 мс</td>
<td align="right">0,38 мс</td>
<td align="right">0,8 мс</td>
<td align="right">0</td>
</tr>
<tr>
<td>Складна</td>
<td align="right">0,48 мс</td>
<td align="right">1,39 мс</td>
<td align="right">5,5 мс</td>
<td align="right">0</td>
</tr>
</tbody></table>
</div>
<p>Крім того, кожну згенеровану головоломку ми незалежно перевірили за допомогою solver. Усі 3 000 мали єдиний розв&#39;язок.</p>
<h2 id="коли-кількості-порожніх-клітинок-не-досить"><a class="heading-anchor" href="#коли-кількості-порожніх-клітинок-не-досить" aria-hidden="true" tabindex="-1">#</a>Коли кількості порожніх клітинок не досить</h2>
<p>Кількість підказок є грубою мірою складності. Дві головоломки з 26 цифрами можуть бути різної складності залежно від того, яких логічних прийомів вони вимагають. Точніша оцінка розв&#39;язувала б головоломку як людина й фіксувала найскладніший прийом, який довелося застосувати. Для мобільної гри, де гравець обирає один із трьох рівнів, кількості підказок достатньо.</p>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/crosswords/1-crosswords.jpg" alt="" width="698" height="1516" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Спробуйте</p><h2 class="app-box-title">Кросворди та головоломки</h2><p>Кросворди, пошук слів, судоку та японські кросворди в одному офлайн-застосунку. Як створюються головоломки і що нового.</p><div class="app-box-links"><a class="btn btn-primary" href="https://smallwater.eu/crosswords-ios" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-ios" data-ga-app="crosswords" data-ga-store="app_store">App Store</a><a class="btn btn-ghost" href="https://smallwater.eu/crosswords-android" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-android" data-ga-app="crosswords" data-ga-store="google_play">Google Play</a></div></div></aside>
<p>Якщо ви розв&#39;язуєте схожу задачу, генеруєте головоломки або вам потрібен мобільний застосунок, <a href="https://smallwater.eu/ua/#kontakt">напишіть нам</a>. Як згенерувати пошук слів із зашифрованим словом, описано в <a href="https://smallwater.eu/ua/blog/word-search-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/sudoku-generator/" data-ga-destination="/ua/blog/word-search-generator/" data-ga-placement="inline">окремій інструкції</a>. Сканворду із зашифрованим словом присвячена <a href="https://smallwater.eu/ua/blog/swedish-crossword-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/sudoku-generator/" data-ga-destination="/ua/blog/swedish-crossword-generator/" data-ga-placement="inline">ще одна інструкція</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Як згенерувати сканворд із зашифрованим словом</title>
      <link>https://smallwater.eu/ua/blog/swedish-crossword-generator/</link>
      <guid isPermaLink="true">https://smallwater.eu/ua/blog/swedish-crossword-generator/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Кросворди та головоломки</category>
      <description>Посібник зі створення генератора сканвордів: маска через hill climbing, заповнення словами як CSP з backtracking і зашифроване слово в готовій сітці.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/cover.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/cover.jpg" alt="Два сканворди 10×10: ліворуч випадкова маска з оштрафованими клітинками, праворуч заповнена сітка з кораловими літерами зашифрованого слова" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Зміст"><p class="post-toc-title">Зміст</p><ol><li><a href="#яким-вимогам-має-відповідати-сканворд" data-ga="blog_toc_clicked" data-ga-heading-id="яким-вимогам-має-відповідати-сканворд">Яким вимогам має відповідати сканворд</a></li><li><a href="#чому-не-вистачить-заповнювати-сітку-слово-за-словом" data-ga="blog_toc_clicked" data-ga-heading-id="чому-не-вистачить-заповнювати-сітку-слово-за-словом">Чому не вистачить заповнювати сітку слово за словом</a></li><li><a href="#крок-1-маска-через-hill-climbing" data-ga="blog_toc_clicked" data-ga-heading-id="крок-1-маска-через-hill-climbing">Крок 1: маска через hill climbing</a></li><li><a href="#крок-2-заповнення-словами-як-csp" data-ga="blog_toc_clicked" data-ga-heading-id="крок-2-заповнення-словами-як-csp">Крок 2: заповнення словами як CSP</a></li><li><a href="#крок-3-зашифроване-слово-в-готовіи-сітці" data-ga="blog_toc_clicked" data-ga-heading-id="крок-3-зашифроване-слово-в-готовіи-сітці">Крок 3: зашифроване слово в готовій сітці</a></li><li><a href="#чому-не-ставити-зашифроване-слово-заздалегідь-як-у-газеті" data-ga="blog_toc_clicked" data-ga-heading-id="чому-не-ставити-зашифроване-слово-заздалегідь-як-у-газеті">Чому не ставити зашифроване слово заздалегідь, як у газеті</a></li><li><a href="#продуктивність-генератора-і-чому-він-не-працює-на-телефоні" data-ga="blog_toc_clicked" data-ga-heading-id="продуктивність-генератора-і-чому-він-не-працює-на-телефоні">Продуктивність генератора і чому він не працює на телефоні</a></li><li><a href="#генерація-у-великих-обсягах" data-ga="blog_toc_clicked" data-ga-heading-id="генерація-у-великих-обсягах">Генерація у великих обсягах</a></li><li><a href="#чого-цеи-підхід-не-охоплює" data-ga="blog_toc_clicked" data-ga-heading-id="чого-цеи-підхід-не-охоплює">Чого цей підхід не охоплює</a></li></ol></nav><p>Сканворд (шведський кросворд) із зашифрованим словом можна згенерувати у дві фази: спершу локальним пошуком знайдіть маску, тобто розташування клітинок із питаннями і клітинок для літер, а потім заповніть її словами зі словника як задачу з обмеженнями (CSP) за допомогою backtracking. Не ставте зашифроване слово в сітку заздалегідь. Лише коли кросворд готовий, шукайте жарт, чию пуанту можна прочитати в ньому зліва направо і згори донизу.</p>
<p>Так створюються сканворди в нашому застосунку з кросвордами та пошуком слів (<a href="https://smallwater.eu/crosswords-ios">App Store</a>, <a href="https://smallwater.eu/crosswords-android">Google Play</a>). Одна сітка коштує від кількох до кількох десятків секунд процесорного часу, тому ми генеруємо їх заздалегідь на потужнішому комп&#39;ютері і додаємо до застосунку. Пошук слів генерується зовсім інакше і простіше, про це стаття <a href="https://smallwater.eu/ua/blog/word-search-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/swedish-crossword-generator/" data-ga-destination="/ua/blog/word-search-generator/" data-ga-placement="inline">Як згенерувати пошук слів із зашифрованим словом</a>.</p>
<h2 id="яким-вимогам-має-відповідати-сканворд"><a class="heading-anchor" href="#яким-вимогам-має-відповідати-сканворд" aria-hidden="true" tabindex="-1">#</a>Яким вимогам має відповідати сканворд</h2>
<p>У сканворді немає чорних клітинок. Кожна клітинка є або літерою, або питанням зі стрілкою праворуч чи вниз. Звідси жорсткі умови:</p>
<ul>
<li>Кожна літера належить і горизонтальному, і вертикальному слову. Літеру, яку перетинає лише одне слово, неможливо перевірити, і в кросворді вона виглядає як помилка.</li>
<li>Кожне слово має щонайменше дві літери, а перед ним стоїть питання. Тому перший рядок і перший стовпець завжди складаються лише з питань.</li>
<li>Літери утворюють одну суцільну область без відірваних острівців.</li>
<li>Кожне слово має бути у словнику і мати щонайменше одне визначення. Жодне слово в сітці не повторюється.</li>
</ul>
<p>До цього додаються м&#39;які критерії якості: мало двобуквених слів, жодних питань, що нікуди не вказують, і якомога менше дуже довгих слів, які важко заповнити. Задача обчислювально складна. <a href="https://link.springer.com/chapter/10.1007/978-3-642-30347-0_15" target="_blank" rel="noopener">Engel та ін. (2012)</a> показали, що вже саме заповнення маски словами є NP-повною задачею.</p>
<h2 id="чому-не-вистачить-заповнювати-сітку-слово-за-словом"><a class="heading-anchor" href="#чому-не-вистачить-заповнювати-сітку-слово-за-словом" aria-hidden="true" tabindex="-1">#</a>Чому не вистачить заповнювати сітку слово за словом</h2>
<p>Перша ідея: діяти як людина з олівцем. Поділити зашифроване слово на слова, покласти їх у рядок, як у газеті, провести через кожну його літеру слово, що перетинає, а решту сітки заповнити за допомогою greedy algorithm. Greedy algorithm на кожному кроці бере варіант, який виглядає найкращим саме зараз, і ніколи не переглядає рішення. Де нічого не підходить, лишається дірка. А оскільки greedy-заповнення не вміє повертатися, погані перші десять слів решта сітки вже не виправить.</p>
<p>Краща евристика вибору слів допомагає лише трохи. Можна пробувати довжини слів, що перетинають, від середніх, близько п&#39;яти літер, до крайніх. Або піти еволюційним шляхом: популяція недороблених кросвордів, від кожного п&#39;ять нащадків з одним словом більше, оцінка за кількістю заповнених клітинок, кількістю голосних і рівномірністю літер, і до наступного покоління переходять лише найкращі. Результати стають трохи щільнішими, але дірки лишаються, бо евристика бачить лише найближчий крок.</p>
<p>Вихід дає бакалаврська робота Якоба Енгеля з Технічного університету Мюнхена (2009); він також перший автор згаданої статті. Розділіть задачу на дві незалежні фази: спершу знайдіть добру маску, не зважаючи на слова, потім заповніть її. Енгель для маски використав еволюційний алгоритм. Ми використовуємо простіший локальний пошук із випадковими струсами, описаний у наступному кроці.</p>
<h2 id="крок-1-маска-через-hill-climbing"><a class="heading-anchor" href="#крок-1-маска-через-hill-climbing" aria-hidden="true" tabindex="-1">#</a>Крок 1: маска через hill climbing</h2>
<p>Маскою називаємо сітку, в якій кожна клітинка або питання, або літера. Щоб її знайти, достатньо hill climbing: локального пошуку, який робить малі зміни і зберігає ті, що не погіршують результат.</p>
<p>Почніть випадково, з питаннями приблизно на 22 % внутрішніх клітинок. Кожній масці призначте штрафну оцінку. Жорсткі порушення (закоротке слово, літера без слова, що перетинає, відірваний острівець) нехай здебільшого коштують 1 000 балів, м&#39;які недоліки (двобуквені слова понад ліміт, питання, що не вказує на жодне слово, скупчення питань) від 50 до 500.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/mask-random.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/mask-random.png" alt="Випадкова маска 10×10 з великою кількістю проблемних клітинок, позначених червоним і помаранчевим" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>Випадкова маска. Червоні клітинки є літерами з порушенням, помаранчеві питаннями, що нікуди не вказують. Штраф 35 500.</figcaption></figure>
<p>Далі в циклі змінюйте одну клітинку з питання на літеру або навпаки, або зсувайте питання на клітинку по діагоналі. Залиште зміну, якщо штраф не зріс, інакше скасуйте її. Спрямуйте половину змін на клітинки з найбільшим штрафом, і грубі помилки зникнуть швидко:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/mask-mid.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/mask-mid.png" alt="Та сама маска після 150 кроків, проблемних клітинок лишилося кілька" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>Після 150 кроків. Штраф 1 770.</figcaption></figure>
<p>Hill climbing легко застрягає в локальному мінімумі. Коли 2 000 кроків нічого не покращують, «струсніть» сітку: у найкращій досі знайденій масці перемкніть п&#39;ять випадкових клітинок і шукайте далі. На графіку це піки. Якщо й після трьох струсів кращої маски немає, а поточна не має жорстких порушень, зупиніться.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/penalty.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/penalty.png" alt="Графік штрафу в логарифмічній шкалі протягом 8 300 кроків: швидке падіння з 35 500 до 200 і три короткі піки після струсів" width="1800" height="850" loading="lazy" decoding="async" /></picture><figcaption>Штраф під час генерації маски 10×10. Точки позначають два попередні знімки.</figcaption></figure>
<p>Готова маска має залишковий штраф 200 за м&#39;які недоліки, але жодного жорсткого порушення:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/mask-final.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/mask-final.png" alt="Готова маска 10×10 зі стрілками в клітинках питань і порожніми клітинками для літер" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>Готова маска. Стрілки показують напрямок слова.</figcaption></figure>
<p>Штрафи за питання без слова і за відірваний острівець варто мати в масці від самого початку. На великому наборі готових кросвордів без них 13,2 % питань не вказували на жодне слово, а 23,5 % сіток мали відірваний острівець літер. З ними це 5,0 % і 0 %.</p>
<h2 id="крок-2-заповнення-словами-як-csp"><a class="heading-anchor" href="#крок-2-заповнення-словами-як-csp" aria-hidden="true" tabindex="-1">#</a>Крок 2: заповнення словами як CSP</h2>
<p>З маски виходить список слотів, тобто місць для слів із відомою довжиною і перетинами. Заповнення є класичною задачею задоволення обмежень (constraint satisfaction problem, CSP): кожен слот є змінною, його домен складають слова, які в нього вміщуються, а обмеження є однаковими літерами на перетинах. Розв&#39;язується вона за допомогою backtracking, тобто пошуку, який у глухій гілці повертається на крок назад і пробує інший варіант:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">для кожного слота: домен = усі слова цієї довжини
поки не всі слоти заповнені:
    вибери слот із найменшим доменом (за рівності той, що має найбільше перетинів)
    для кожного слова з домену, від найкращого:
        постав слово
        звузь домени слотів, що перетинають, до слів із тією самою літерою
        якщо якийсь домен спорожнів: скасуй, спробуй наступне слово
        інакше переходь до наступного слота
    якщо жодне слово не підходить: повернися на слот назад
після 50 000 кроків здайся</code></pre>
<p>У літературі ці евристики називаються MRV (minimum remaining values, спершу слот із найменшою кількістю варіантів) і forward checking (після кожного кроку звужувати домени сусідів, щоб глуха гілка виявлялася одразу, а не на десять слів пізніше). Заздалегідь проіндексуйте словник за довжиною, позицією і літерою. Тоді «слова з Щ на третьому місці» є одним перетином множин, а не проходом усім словником.</p>
<p>Важливий і порядок слів. Надавайте перевагу словам, які мають у словнику більше різних визначень. Це простий сигнал поширеного кросвордного слова. Без нього сітка заповнюється маловідомими абревіатурами.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/filled.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/filled.png" alt="Маска 10×10, заповнена англійськими словами" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>Заповнена сітка. Ілюстрації використовують англійський словник, числа в таблицях отримані на словацькому.</figcaption></figure>
<h2 id="крок-3-зашифроване-слово-в-готовіи-сітці"><a class="heading-anchor" href="#крок-3-зашифроване-слово-в-готовіи-сітці" aria-hidden="true" tabindex="-1">#</a>Крок 3: зашифроване слово в готовій сітці</h2>
<p>Під час заповнення ви ще не знаєте, яким буде зашифроване слово. Прочитайте літери готової сітки зліва направо і згори донизу і шукайте жарт, чия пуанта є підпослідовністю цього рядка. Тобто літери йдуть у правильному порядку, але не обов&#39;язково поруч. Якщо такий жарт знайдено, розподіліть його літери по сітці якомога рівномірніше, щоб пуанту не можна було вгадати з одного слова.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/secret-order.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/secret-order.png" alt="Заповнена сітка з десятьма кораловими літерами S, P, A, C, E, S, U, I, T, S, пронумерованими в порядку читання" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>Зашифроване слово SPACESUITS у порядку читання. У цю сітку вмістилося б 111 різних пуант з нашого англійського списку жартів.</figcaption></figure>
<p>Такий порядок (спершу сітка, потім жарт) суттєво збільшує кількість готових кросвордів. За нашими вимірюваннями одна сітка 12×12 у середньому підходить для 86 жартів зі словацького списку (5 960 жартів). Тож завжди знайдеться такий, що ще не використовувався в цьому розмірі.</p>
<h2 id="чому-не-ставити-зашифроване-слово-заздалегідь-як-у-газеті"><a class="heading-anchor" href="#чому-не-ставити-зашифроване-слово-заздалегідь-як-у-газеті" aria-hidden="true" tabindex="-1">#</a>Чому не ставити зашифроване слово заздалегідь, як у газеті</h2>
<p>У газетах зашифроване слово зазвичай стоїть в одному місці, в рядку і розділене на слова. Досягти цього можна двома способами, і обидва дають дуже мало результатів.</p>
<p>Перший спосіб: поставити зашифроване слово в сітку заздалегідь. Тоді воно має вміститися. Наші пуанти мають у середньому 10,3 літери і 2 слова. Ось частка жартів зі словацького списку, які взагалі можна покласти в сітку даного розміру:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Сітка</th>
<th align="right">Уся пуанта в одному рядку</th>
<th align="right">Пуанта, розділена на слова (не більше 3)</th>
</tr>
</thead>
<tbody><tr>
<td>8×8</td>
<td align="right">0 %</td>
<td align="right">22 %</td>
</tr>
<tr>
<td>10×10</td>
<td align="right">0,1 %</td>
<td align="right">64 %</td>
</tr>
<tr>
<td>12×12</td>
<td align="right">45 %</td>
<td align="right">89 %</td>
</tr>
<tr>
<td>15×15</td>
<td align="right">84 %</td>
<td align="right">98 %</td>
</tr>
</tbody></table>
</div>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/swedish-crossword-generator/newspaper.webp" /><img src="https://smallwater.eu/blog/swedish-crossword-generator/newspaper.png" alt="Сітка 10×10, у якій слова зашифрованого слова KNOW і RULES заздалегідь покладені як цілі горизонтальні слова коралового кольору" width="968" height="978" loading="lazy" decoding="async" /></picture><figcaption>Зашифроване слово, поставлене заздалегідь цілими словами в рядках, як у газеті. Згенеровано тим самим генератором.</figcaption></figure>
<p>Із заздалегідь поставленим зашифрованим словом заповнення малих сіток вдається приблизно так само часто, як без нього. Для 15×15 це було 5 із 60 спроб проти 8 без нього, для 18×18 жодної з 60. До того ж кожна така сітка слугує лише одному жарту, а в малих розмірах більшість жартів навіть не вдається покласти.</p>
<p>Другий спосіб: дати сітці утворитися вільно і шукати в ній пуанту як суцільний відрізок. На вибірці з 2 200 готових словацьких кросвордів (по 200 кожного розміру від 8×8 до 18×18) уся пуанта як суцільний відрізок в одному рядку чи стовпці знайшлася лише в 81 сітці (3,7 %). Якщо кожне слово пуанти може лежати деінде, таких сіток 443 (20 %), і це ще без умови, що слова стоять поруч. Якщо читати літери по порядку, жарт знайшовся в кожній із них. Ціною зашифрованого слова, розкиданого по сітці, ви отримуєте на порядки більше кросвордів.</p>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/crosswords/1-crosswords.jpg" alt="" width="698" height="1516" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Спробуйте</p><h2 class="app-box-title">Кросворди та головоломки</h2><p>Кросворди, пошук слів, судоку та японські кросворди в одному офлайн-застосунку. Як створюються головоломки і що нового.</p><div class="app-box-links"><a class="btn btn-primary" href="https://smallwater.eu/crosswords-ios" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-ios" data-ga-app="crosswords" data-ga-store="app_store">App Store</a><a class="btn btn-ghost" href="https://smallwater.eu/crosswords-android" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-android" data-ga-app="crosswords" data-ga-store="google_play">Google Play</a></div></div></aside>
<h2 id="продуктивність-генератора-і-чому-він-не-працює-на-телефоні"><a class="heading-anchor" href="#продуктивність-генератора-і-чому-він-не-працює-на-телефоні" aria-hidden="true" tabindex="-1">#</a>Продуктивність генератора і чому він не працює на телефоні</h2>
<p>Генератор виконав по 60 спроб для кожного розміру зі словацьким словником, в одному потоці (JavaScript на Node 22, серверний процесор Intel Xeon 2,8 ГГц у хмарі). Спроба складається з однієї маски й одного заповнення. «Успішне заповнення» означає, що CSP знайшов розв&#39;язок за 50 000 кроків.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Сітка</th>
<th align="right">Маска</th>
<th align="right">Заповнення</th>
<th align="right">Успішне заповнення</th>
<th align="right">Слів у сітці</th>
<th align="right">Жартів на сітку</th>
</tr>
</thead>
<tbody><tr>
<td>8×8</td>
<td align="right">6 мс</td>
<td align="right">0,50 с</td>
<td align="right">5 %</td>
<td align="right">21</td>
<td align="right">0 (з 3 сіток)</td>
</tr>
<tr>
<td>10×10</td>
<td align="right">0,16 с</td>
<td align="right">0,86 с</td>
<td align="right">20 %</td>
<td align="right">33</td>
<td align="right">26</td>
</tr>
<tr>
<td>12×12</td>
<td align="right">0,25 с</td>
<td align="right">1,45 с</td>
<td align="right">43 %</td>
<td align="right">52</td>
<td align="right">86</td>
</tr>
<tr>
<td>15×15</td>
<td align="right">0,32 с</td>
<td align="right">1,82 с</td>
<td align="right">13 %</td>
<td align="right">80</td>
<td align="right">424</td>
</tr>
<tr>
<td>18×18</td>
<td align="right">0,52 с</td>
<td align="right">1,82 с</td>
<td align="right">13 %</td>
<td align="right">118</td>
<td align="right">725</td>
</tr>
</tbody></table>
</div>
<p>Що показують вимірювання:</p>
<ul>
<li><strong>Мала сітка не є легкою.</strong> У сітці 8×8 багато коротких слотів із великою кількістю перетинів і мало свободи, заповнення вдається лише в 5 % спроб. Одна заповнена сітка в середньому коштує близько 10 секунд процесорного часу для 8×8, 4 секунди для 12×12 і 18 секунд для 18×18.</li>
<li><strong>Більшість спроб закінчується невдачею.</strong> Тому запускайте спроби паралельно на всіх ядрах і беріть перший достатньо добрий результат.</li>
<li><strong>На телефоні це не має сенсу.</strong> Гравець не хоче чекати десятки секунд, відкриваючи екран, а застосунок мусив би містити весь словник із визначеннями. До того ж генератор у застосунку мусить постійно віддавати керування інтерфейсу, інакше застосунок зависає.</li>
</ul>
<h2 id="генерація-у-великих-обсягах"><a class="heading-anchor" href="#генерація-у-великих-обсягах" aria-hidden="true" tabindex="-1">#</a>Генерація у великих обсягах</h2>
<p>Тому генератор має працювати на комп&#39;ютері з багатоядерним процесором. У нас кожне ядро має власний потік зі словником, завантаженим один раз під час запуску. Сітка готова, щойно один із потоків поверне результат із достатньо малою кількістю невикористаних клітинок. У безперервному режимі генератор по колу створює один кросворд кожного розміру кожною мовою. Так з&#39;явилося понад 14 000 кросвордів чотирма мовами (словацька, чеська, англійська, німецька) в одинадцяти розмірах від 8×8 до 18×18. Кожен жарт використовується не більше одного разу на розмір і мову.</p>
<p>За такого обсягу важать дві речі:</p>
<ul>
<li><strong>Формат.</strong> Повний запис займає близько 28 кБ на кросворд і 390 МБ на весь корпус. Більшу частину можна обчислити (координати, типи клітинок, номери питань). Компактний формат, у якому сітка зберігається одним рядком, займає близько 2 кБ, 15 МБ на корпус і приблизно 7 МБ в інсталяційному пакеті застосунку.</li>
<li><strong>Вимірювання якості.</strong> Після кожної генерації вимірюйте частку питань без слова і сіток з острівцями, і нехай перевірка не проходить, якщо показники погіршуються.</li>
</ul>
<h2 id="чого-цеи-підхід-не-охоплює"><a class="heading-anchor" href="#чого-цеи-підхід-не-охоплює" aria-hidden="true" tabindex="-1">#</a>Чого цей підхід не охоплює</h2>
<ul>
<li><strong>Питання без слів.</strong> Середній кросворд досі має 2,7 питання, що нікуди не вказують. Лівий верхній кут завжди такий.</li>
<li><strong>Словники без частотності.</strong> Німецький словник із Вікісловника має для більшості слів одне визначення, тож сортування за кількістю визначень майже нічого не розрізняє, і в сітку потрапляють рідкісні слова. Допоміг би частотний словник, поки що його немає.</li>
<li><strong>Зашифроване слово не стоїть в одному місці.</strong> Хто звик до газет, може шукати його в рядку. Це компроміс, а не ідеал.</li>
</ul>
<p>Якщо ви розв&#39;язуєте схожу задачу з генерацією контенту або вам потрібен мобільний застосунок, <a href="https://smallwater.eu/ua/#kontakt">напишіть нам</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Як згенерувати 3D-модель телефону в браузері</title>
      <link>https://smallwater.eu/ua/blog/threejs-csg-3d-phone-model/</link>
      <guid isPermaLink="true">https://smallwater.eu/ua/blog/threejs-csg-3d-phone-model/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Ecranify</category>
      <description>Інструкція: 3D-модель телефону в браузері з Three.js і CSG. Корпус із bevel, вирізи для кнопок і динаміків, Web Worker і швидке віднімання всіх отворів.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/cover.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/cover.jpg" alt="Дві згенеровані 3D-моделі телефону: одна спереду з темним дисплеєм, друга ззаду" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Зміст"><p class="post-toc-title">Зміст</p><ol><li><a href="#що-вам-знадобиться" data-ga="blog_toc_clicked" data-ga-heading-id="що-вам-знадобиться">Що вам знадобиться</a></li><li><a href="#крок-1-витягніть-прямокутник-у-3d" data-ga="blog_toc_clicked" data-ga-heading-id="крок-1-витягніть-прямокутник-у-3d">Крок 1: Витягніть прямокутник у 3D</a></li><li><a href="#крок-2-виріжте-заглиблення-для-дисплея" data-ga="blog_toc_clicked" data-ga-heading-id="крок-2-виріжте-заглиблення-для-дисплея">Крок 2: Виріжте заглиблення для дисплея</a></li><li><a href="#крок-3-підготуите-бічні-елементи-як-свердла" data-ga="blog_toc_clicked" data-ga-heading-id="крок-3-підготуите-бічні-елементи-як-свердла">Крок 3: Підготуйте бічні елементи як свердла</a></li><li><a href="#крок-4-не-свердліть-передні-деталі" data-ga="blog_toc_clicked" data-ga-heading-id="крок-4-не-свердліть-передні-деталі">Крок 4: Не свердліть передні деталі</a></li><li><a href="#крок-5-перенесіть-обчислення-у-web-worker" data-ga="blog_toc_clicked" data-ga-heading-id="крок-5-перенесіть-обчислення-у-web-worker">Крок 5: Перенесіть обчислення у Web Worker</a></li><li><a href="#крок-6-відніміть-усі-отвори-одразу" data-ga="blog_toc_clicked" data-ga-heading-id="крок-6-відніміть-усі-отвори-одразу">Крок 6: Відніміть усі отвори одразу</a></li><li><a href="#крок-7-тримаите-кількість-сегментів-як-параметр" data-ga="blog_toc_clicked" data-ga-heading-id="крок-7-тримаите-кількість-сегментів-як-параметр">Крок 7: Тримайте кількість сегментів як параметр</a></li><li><a href="#рендер-на-сервері-без-відеокарти" data-ga="blog_toc_clicked" data-ga-heading-id="рендер-на-сервері-без-відеокарти">Рендер на сервері без відеокарти</a></li></ol></nav><p>Щоб згенерувати 3D-модель телефону в браузері, витягніть прямокутник із заокругленими кутами на глибину пристрою, а потім булевими операціями (CSG) виріжте передню панель, кнопки, динаміки й роз&#39;єм. Увесь пристрій тоді описують кілька чисел у міліметрах, і нова модель означає зміну цих чисел, а не моделювання в 3D-програмі.</p>
<p>Це корисно всюди, де потрібно багато схожих пристроїв або де їх має налаштовувати користувач: конфігуратори, мокапи, прев&#39;ю продуктів. Приклад із практики: <a href="https://ecranify.com" target="_blank" rel="noopener">Ecranify</a>, інструмент для 3D-мокапів для скриншотів в App Store і Google Play, у якому немає жодної готової 3D-моделі. Нижче покроковий процес разом із вимірюванням, яке показує, де на обчисленнях можна заощадити найбільше.</p>
<h2 id="що-вам-знадобиться"><a class="heading-anchor" href="#що-вам-знадобиться" aria-hidden="true" tabindex="-1">#</a>Що вам знадобиться</h2>
<ul>
<li><a href="https://threejs.org" target="_blank" rel="noopener">Three.js</a> для сцени, камер, світла і передусім геометрії. Геометрія в Three.js це mesh, тобто набір трикутників.</li>
<li>Бібліотека <a href="https://github.com/gkjohnson/three-bvh-csg" target="_blank" rel="noopener">three-bvh-csg</a> для булевих операцій над mesh. Вона пришвидшує їх просторовим індексом (BVH, bounding volume hierarchy), тому не перевіряє кожен трикутник проти кожного.</li>
<li>Опис пристрою як дані, наприклад JSON із розмірами корпусу, радіусом кутів, розміром bevel і списком елементів на боках. Усі позиції задавайте в міліметрах від центру пристрою.</li>
</ul>
<h2 id="крок-1-витягніть-прямокутник-у-3d"><a class="heading-anchor" href="#крок-1-витягніть-прямокутник-у-3d" aria-hidden="true" tabindex="-1">#</a>Крок 1: Витягніть прямокутник у 3D</h2>
<p>Намалюйте 2D-прямокутник із заокругленими кутами в реальному розмірі, наприклад 163,4 × 78 мм, і витягніть його (екструзія) на глибину 8,75 мм. Отримаєте пластину з гострими гранями. Таких телефонів не буває, тому під час екструзії увімкніть ще й bevel, тобто заокруглення ребра. Three.js робить обидва кроки за один раз.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Лише екструзія</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/slab-sharp.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/slab-sharp.jpg" alt="Кутаста пластина без заокруглених кутів і ребер" width="1200" height="800" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">+ кути й bevel</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/slab-bevel.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/slab-bevel.jpg" alt="Та сама пластина із заокругленими кутами й ребрами" width="1200" height="800" loading="lazy" decoding="async" /></picture></div></div><figcaption>Та сама форма, два інші числа: радіус кута й розмір bevel.</figcaption></figure>
<p>Зверніть увагу: bevel додається назовні. Якщо потрібен точний зовнішній розмір, зменште 2D-форму та глибину на розмір bevel.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">b = min(bevel, глибина / 2)
форма = заокруглений прямокутник(ширина - 2b, висота - 2b, радіус кута - b)
корпус = витягни(форма, глибина - 2b, bevel b)</code></pre>
<p>Гладкість дуг (тесселяцію) визначає кількість сегментів, на які ділиться дуга. Більше сегментів означає гладшу форму й більше трикутників. Це число згодом визначить швидкість усього обчислення, тож ми повернемося до нього в кроці 7.</p>
<h2 id="крок-2-виріжте-заглиблення-для-дисплея"><a class="heading-anchor" href="#крок-2-виріжте-заглиблення-для-дисплея" aria-hidden="true" tabindex="-1">#</a>Крок 2: Виріжте заглиблення для дисплея</h2>
<p>CSG (constructive solid geometry) складає тіла з простіших тіл за допомогою трьох операцій: об&#39;єднання, різниця й перетин. Різниця працює як свердло: одне тіло забирає матеріал з іншого.</p>
<p>Дисплей телефону не врівень із рамкою, він лежить у неглибокому заглибленні. Щоб його зробити, створіть другу копію корпусу, меншу на 1,4 мм, залиште з неї лише передню частину завтовшки 0,7 мм (перетин із великим паралелепіпедом) і відніміть цю частину від корпусу. Потім покладіть у заглиблення темну площину дисплея.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Різальне тіло</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/pocket-cutter.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/pocket-cutter.jpg" alt="Корпус телефону із синім напівпрозорим різальним тілом на передній панелі" width="1200" height="800" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Після віднімання</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/pocket-carved.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/pocket-carved.jpg" alt="Корпус із вирізаним заглибленням і темним дисплеєм усередині" width="1200" height="800" loading="lazy" decoding="async" /></picture></div></div><figcaption>Синє тіло віднімається. Праворуч дисплей уже лежить у заглибленні.</figcaption></figure>
<p>Дві практичні деталі:</p>
<ul>
<li><strong>Паралелепіпед для перетину має бути справді великим.</strong> Йому достатньо обрізати глибину, але за шириною й висотою він має перевищувати весь пристрій. Не виводьте його з глибини: наприклад, 20-кратна глибина для тонкого високого телефону менша за його висоту, і вгорі та внизу залишиться необрізана рамка.</li>
<li><strong>Збільште різальне тіло на кілька проміле.</strong> Коли два тіла мають точно однакову грань, CSG не може визначити, яка сторона всередині. Масштаб 1,002 цьому запобігає, і оком його не видно.</li>
</ul>
<h2 id="крок-3-підготуите-бічні-елементи-як-свердла"><a class="heading-anchor" href="#крок-3-підготуите-бічні-елементи-як-свердла" aria-hidden="true" tabindex="-1">#</a>Крок 3: Підготуйте бічні елементи як свердла</h2>
<p>Кожен елемент на боці робиться за тим самим рецептом з іншим свердлом. Кнопка, наприклад, складається з трьох тіл: заокруглений прямокутник, трохи глибший за кнопку, віднімається від рамки, край отвору отримує невелику фаску, а в отвір вставляється трохи менше тіло, яке злегка виступає з рамки.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Свердла</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/button-cutters.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/button-cutters.jpg" alt="Бік телефону з двома синіми різальними тілами для кнопок" width="1200" height="800" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Виріз і кнопка</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/button-cut.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/button-cut.jpg" alt="Бік телефону з вирізаними отворами й вставленими кнопками" width="1200" height="800" loading="lazy" decoding="async" /></picture></div></div><figcaption>Дві кнопки на правому боці до і після віднімання.</figcaption></figure>
<p>Динаміки це ряд маленьких циліндрів, роз&#39;єм це заокруглений прямокутник. У кожен отвір вставте щось темне: тонкий диск у решітку, пластинку в роз&#39;єм. Порожня дірка в тонкій оболонці виглядає як помилка, дірка з темним заповненням виглядає як залізо.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/bottom-edge.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/bottom-edge.jpg" alt="Нижній край телефону з двома рядами отворів динаміка й роз&#39;ємом посередині" width="1200" height="800" loading="lazy" decoding="async" /></picture><figcaption>Отвори динаміка й роз&#39;єм. Усе це відняті циліндри й заокруглені прямокутники.</figcaption></figure>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">для кожного елемента в списку бічних елементів:
    свердло = форма елемента (циліндр, заокруглений прямокутник, ...)
    розмісти свердло на ребрі за позицією в мм
    додай свердло до списку вирізів
    якщо елемент має заповнення: додай заповнення до сцени</code></pre>
<p>Додати кнопку тоді означає додати запис у дані. Елементів швидко більшає: iPad Pro 13″ має 58 отворів динаміка, 4 кнопки, 2 антени й роз&#39;єм, тобто понад 60 віднімань від одного корпусу.</p>
<h2 id="крок-4-не-свердліть-передні-деталі"><a class="heading-anchor" href="#крок-4-не-свердліть-передні-деталі" aria-hidden="true" tabindex="-1">#</a>Крок 4: Не свердліть передні деталі</h2>
<p>Виріз камери, острівець у верхній частині дисплея й індикатор домашнього екрана пласкі. Різати для них геометрію марна робота. Нанесіть їх як decal, тобто зображення, спроєктоване на поверхню. Проєктуйте його на приховану площину на всю передню панель і задавайте позицію в тих самих міліметрах, що й усе інше. Виняток: кругла кнопка «Додому» старших iPhone. Вона має справжню глибину, яку видно збоку, тому проходить через CSG.</p>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/ecranify/home.png" alt="" width="1400" height="1219" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Спробуйте</p><h2 class="app-box-title">Ecranify</h2><p>Браузерний інструмент для фотореалістичних 3D-мокапів пристроїв для скриншотів у App Store та Google Play. Нотатки про WebGL і новини.</p><div class="app-box-links"><a class="btn btn-primary" href="https://ecranify.com" target="_blank" rel="noopener" data-ga="blog_app_link_clicked" data-ga-category="ecranify" data-ga-destination="https://ecranify.com" data-ga-app="ecranify" data-ga-store="web">ecranify.com</a></div></div></aside>
<h2 id="крок-5-перенесіть-обчислення-у-web-worker"><a class="heading-anchor" href="#крок-5-перенесіть-обчислення-у-web-worker" aria-hidden="true" tabindex="-1">#</a>Крок 5: Перенесіть обчислення у Web Worker</h2>
<p>JavaScript у браузері працює в одному потоці. Той самий потік рахує, малює сторінку й реагує на кліки. Поки працює CSG, сторінка зависає, і spinner не допоможе, бо йому потрібен той самий потік.</p>
<p>Тому запускайте всю збірку пристрою у <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API" target="_blank" rel="noopener">Web Worker</a>, тобто в окремому фоновому потоці. Worker отримує опис пристрою, повертає готові масиви вершин, і сторінка лишається плавною. Коли параметр змінюється, завершіть старий worker і запустіть новий, щоб він не дораховував застарілий стан.</p>
<p>Результат із worker з&#39;являється не відразу, тому додайте миттєве прев&#39;ю. Показуйте елемент, який редагується, як синього напівпрозорого «привида»: простий паралелепіпед або циліндр із кількома десятками трикутників, який можна збудувати в головному потоці одразу. Коли worker закінчить, привид зникне.</p>
<h2 id="крок-6-відніміть-усі-отвори-одразу"><a class="heading-anchor" href="#крок-6-відніміть-усі-отвори-одразу" aria-hidden="true" tabindex="-1">#</a>Крок 6: Відніміть усі отвори одразу</h2>
<p>Найпряміший спосіб: віднімати елементи по черзі, отвір 1 від корпусу, потім отвір 2 від результату й так далі. Але кожне віднімання заново будує просторовий індекс усього корпусу, а це найдорожча частина обчислення. З десятками елементів час росте дуже швидко.</p>
<p>Краще спершу зібрати всі свердла для даного тіла, об&#39;єднати їх в один mesh і відняти один раз.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">вирізи = []
для кожного елемента: додай його свердло до вирізи
об&#x27;єднаний = об&#x27;єднай усі вирізи в один mesh
корпус = корпус мінус об&#x27;єднаний
якщо об&#x27;єднання не вдалося: віднімай вирізи по одному</code></pre>
<p>Вимірювання на спрощеній копії процесу (Three.js 0.170, three-bvh-csg 0.0.17, Node 22, хмарний сервер із 2 ядрами Intel Xeon 2,1 GHz, медіана з 5 запусків):</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Тіло</th>
<th>Отвори</th>
<th>По одному</th>
<th>Одразу</th>
</tr>
</thead>
<tbody><tr>
<td>телефон, 17 676 трикутників</td>
<td>25</td>
<td>2,47 с</td>
<td>0,26 с</td>
</tr>
<tr>
<td>планшет, 17 676 трикутників</td>
<td>58</td>
<td>5,55 с</td>
<td>0,89 с</td>
</tr>
<tr>
<td>планшет, 68 108 трикутників</td>
<td>58</td>
<td>22,8 с</td>
<td>3,1 с</td>
</tr>
</tbody></table>
</div>
<p>Одна умова: об&#39;єднання й одне віднімання дає той самий результат, що й послідовне віднімання, лише якщо свердла не перекриваються. Елементи розташовані в різних місцях, тож зазвичай це так. Але якщо користувач посуне два елементи один на одного, у місці перекриття може з&#39;явитися дрібний дефект.</p>
<h2 id="крок-7-тримаите-кількість-сегментів-як-параметр"><a class="heading-anchor" href="#крок-7-тримаите-кількість-сегментів-як-параметр" aria-hidden="true" tabindex="-1">#</a>Крок 7: Тримайте кількість сегментів як параметр</h2>
<p>Тесселяція, якої достатньо для показу, для CSG часто надто густа. З 64 сегментами на кут і 32 на bevel корпус має 68 108 трикутників. Половини (32 і 16) на ребрі 2 мм не видно, але корпус має лише 17 676 трикутників, і третій рядок таблиці показує, що це робить із часом.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">64 і 32 сегменти</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/tessellation-64.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/tessellation-64.png" alt="Каркасна модель кута телефону з дуже густою сіткою трикутників" width="1200" height="800" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">32 і 16 сегментів</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/threejs-csg-3d-phone-model/tessellation-32.webp" /><img src="https://smallwater.eu/blog/threejs-csg-3d-phone-model/tessellation-32.png" alt="Каркасна модель того самого кута з удвічі меншою кількістю сегментів" width="1200" height="800" loading="lazy" decoding="async" /></picture></div></div><figcaption>Кут корпусу згори. Праворуч чверть трикутників.</figcaption></figure>
<p>Дрібним елементам із радіусом до 2 мм (отвори динаміка, гвинти) дайте половину сегментів, мініатюрам у галереї ще менше. Тому не задавайте якість як константу в коді, а робіть її параметром збірки.</p>
<h2 id="рендер-на-сервері-без-відеокарти"><a class="heading-anchor" href="#рендер-на-сервері-без-відеокарти" aria-hidden="true" tabindex="-1">#</a>Рендер на сервері без відеокарти</h2>
<p>Якщо ту саму сцену потрібно рендерити й на сервері, наприклад як резерв для експорту, вона працює в Chrome без екрана (headless), який без відеокарти малює програмно. Там кожна неефективність множиться. В Ecranify найважчий планшет без описаних змін упирався в ліміт 15 хвилин. Зі спільними вирізами, меншими текстурами, нижчою тесселяцією й cache готової геометрії за hash опису пристрою рендер триває близько пів хвилини, а повторний близько 5 секунд. Подробиці з інтерактивними прикладами є в <a href="https://ecranify.com/guides/how-we-build-3d-device-mockups" target="_blank" rel="noopener">англомовній статті на ecranify.com</a>.</p>
<p>Якщо ви розв&#39;язуєте схожу задачу з 3D у браузері або потрібен застосунок на замовлення, <a href="https://smallwater.eu/ua/#kontakt">напишіть нам</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Як перенести проєкт з Unity 4 на Unity 6</title>
      <link>https://smallwater.eu/ua/blog/unity-4-to-unity-6-upgrade/</link>
      <guid isPermaLink="true">https://smallwater.eu/ua/blog/unity-4-to-unity-6-upgrade/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Onager</category>
      <description>Як перенести проєкт з Unity 4 на Unity 6 без втрати сцен: конвертація в текст через Unity 2019.4, порт UnityScript на C# зі збереженням GUID і відновлення HUD.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/cover.webp" /><img src="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/cover.jpg" alt="Споруда з блоків у рівні Onager на підлозі з матового скла" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Зміст"><p class="post-toc-title">Зміст</p><ol><li><a href="#що-станеться-якщо-відкрити-проєкт-в-unity-6" data-ga="blog_toc_clicked" data-ga-heading-id="що-станеться-якщо-відкрити-проєкт-в-unity-6">Що станеться, якщо відкрити проєкт в Unity 6</a></li><li><a href="#крок-1-зафіксуите-початковии-стан" data-ga="blog_toc_clicked" data-ga-heading-id="крок-1-зафіксуите-початковии-стан">Крок 1: Зафіксуйте початковий стан</a></li><li><a href="#крок-2-конвертація-в-текст-через-unity-2019-4" data-ga="blog_toc_clicked" data-ga-heading-id="крок-2-конвертація-в-текст-через-unity-2019-4">Крок 2: Конвертація в текст через Unity 2019.4</a></li><li><a href="#крок-3-перевіряите-результат-у-редакторі-а-не-читанням-yaml" data-ga="blog_toc_clicked" data-ga-heading-id="крок-3-перевіряите-результат-у-редакторі-а-не-читанням-yaml">Крок 3: Перевіряйте результат у редакторі, а не читанням YAML</a></li><li><a href="#крок-4-unityscript-на-c-з-тим-самим-guid" data-ga="blog_toc_clicked" data-ga-heading-id="крок-4-unityscript-на-c-з-тим-самим-guid">Крок 4: UnityScript на C# з тим самим GUID</a></li><li><a href="#крок-5-відновіть-hud-з-оригінальних-бінарних-сцен" data-ga="blog_toc_clicked" data-ga-heading-id="крок-5-відновіть-hud-з-оригінальних-бінарних-сцен">Крок 5: Відновіть HUD з оригінальних бінарних сцен</a></li><li><a href="#після-конвертаціі-замініть-старі-обхідні-рішення" data-ga="blog_toc_clicked" data-ga-heading-id="після-конвертаціі-замініть-старі-обхідні-рішення">Після конвертації: замініть старі обхідні рішення</a></li><li><a href="#чого-цеи-підхід-не-розв-язує" data-ga="blog_toc_clicked" data-ga-heading-id="чого-цеи-підхід-не-розв-язує">Чого цей підхід не розв язує</a></li></ol></nav><p>Не відкривайте проєкт з Unity 4 одразу в Unity 6 і тим паче нічого в ньому не зберігайте. Unity 6 завантажує старі бінарні сцени лише частково, а збереження робить втрату остаточною; безпечний шлях лежить через Unity 2019.4, яка вміє і прочитати старий формат, і записати його як текст, і лише потім на Unity 6.</p>
<p>Це стосується проєктів, у яких сцени й префаби збережено в бінарному форматі, а скрипти написано на UnityScript, схожій на JavaScript мові, яку Unity колись підтримувала поряд із C#. Як приклад візьмемо Onager, фізичну гру з катапультою, написану в Unity 4.1.2 на UnityScript. У проєкті було 126 скриптів, 75 сцен і 78 префабів. Після перенесення гра працює на Unity 6000.5 з URP (Universal Render Pipeline) і безкоштовно доступна в <a href="https://smallwater.eu/onager-ios">App Store</a> і <a href="https://smallwater.eu/onager-android">Google Play</a>.</p>
<h2 id="що-станеться-якщо-відкрити-проєкт-в-unity-6"><a class="heading-anchor" href="#що-станеться-якщо-відкрити-проєкт-в-unity-6" aria-hidden="true" tabindex="-1">#</a>Що станеться, якщо відкрити проєкт в Unity 6</h2>
<p>Найпростіший підхід: відкрити проєкт в Unity 6 і перемкнути серіалізацію на Force Text, тобто на збереження асетів як читабельного YAML. Але це не переклад файлів. Unity завантажує кожен асет через свій імпортер і записує назад як YAML, тож усе проходить через завантажувач Unity 6. На кожній сцені він повідомляє про Transform без GameObject і видаляє «завислий» компонент. Збереження робить це остаточним.</p>
<p>Втрату можна виміряти інструментом binary2text, який постачається з редактором і читає серіалізований файл без імпортера. В Onager ми порівняли його результат з отриманим YAML і рахували лише компоненти, що зникли з GameObject, які вціліли:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Компонент</th>
<th align="right">Втрачено</th>
</tr>
</thead>
<tbody><tr>
<td>Camera</td>
<td align="right">130</td>
</tr>
<tr>
<td>ParticleSystem</td>
<td align="right">132</td>
</tr>
<tr>
<td>Transform</td>
<td align="right">167</td>
</tr>
<tr>
<td>BoxCollider</td>
<td align="right">104</td>
</tr>
<tr>
<td>Rigidbody</td>
<td align="right">100</td>
</tr>
<tr>
<td>GUITexture / GUIText</td>
<td align="right">303 / 179</td>
</tr>
</tbody></table>
</div>
<p>Жодна з 75 сцен не пройшла без втрат. Зникли цілі камери для HUD, а головна камера отримала нову, стандартну. Головне: шкода виникає вже під час завантаження, а не під час збереження. Поки проєкт не конвертовано, Unity 6 не повинна його зберігати: жодної сцени, жодного префаба.</p>
<h2 id="крок-1-зафіксуите-початковии-стан"><a class="heading-anchor" href="#крок-1-зафіксуите-початковии-стан" aria-hidden="true" tabindex="-1">#</a>Крок 1: Зафіксуйте початковий стан</h2>
<p>Перш ніж щось змінювати, зробіть знімок екрана кожної сцени й збережіть для кожного оригінального файлу сцени дамп через binary2text. За цими дампами ви перевірятимете камери в кроці 3 і відновлюватимете HUD у кроці 5. Оригінальні бінарні файли залиште в історії git, не видаляйте їх.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/before-level-1-1.webp" /><img src="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/before-level-1-1.jpg" alt="Перший рівень в Unity 6 до ремастера" width="1280" height="720" loading="lazy" decoding="async" /></picture><figcaption>Перший рівень Onager в Unity 6, ще зі старим рендером і оригінальними матеріалами. Зафіксуйте так кожну сцену, перш ніж щось змінювати.</figcaption></figure>
<h2 id="крок-2-конвертація-в-текст-через-unity-2019-4"><a class="heading-anchor" href="#крок-2-конвертація-в-текст-через-unity-2019-4" aria-hidden="true" tabindex="-1">#</a>Крок 2: Конвертація в текст через Unity 2019.4</h2>
<p>Unity 2019.4 є останнім редактором, що вміє і читати, і записувати формат Unity 4.1. Конвертуйте із закритим редактором в окремій копії репозиторію:</p>
<ul>
<li><strong>Версія проєкту.</strong> У файлі з версією проєкту вкажіть початкову версію (для Onager 4.1.2), щоб редактор пішов шляхом для старих проєктів. Unity перезапише її під час відкриття, вона має діяти лише для першого імпорту.</li>
<li><strong>Порожні пакети.</strong> Очистіть список залежностей пакетів. Одна помилка компіляції означає, що не працюватиме жоден пункт меню з редакторських скриптів, і це виглядає так, ніби меню взагалі не з&#39;явилося.</li>
<li><strong>Імпорт з нуля.</strong> Видаліть теку Library і дайте проєкту імпортуватися.</li>
<li><strong>Force Text і примусовий перезапис.</strong> Після перемикання на Force Text Unity 2019.4 сама не перезаписує наявні файли. Це треба викликати функцією ресеріалізації асетів, і лише для асетів, а не для їхніх meta-файлів. Перезапис усіх meta-файлів призводить до того, що Unity 6 потім імпортує без кінця.</li>
</ul>
<p>Одна пастка в git. Якщо в атрибутах git позначити сцени як текст із закінченням рядка LF, git під час додавання до репозиторію викидає з бінарних сцен байти, схожі на закінчення рядка. В Onager це пошкодило вісім сцен. Не ставте на асети Unity атрибут закінчення рядків, Unity і так пише LF.</p>
<h2 id="крок-3-перевіряите-результат-у-редакторі-а-не-читанням-yaml"><a class="heading-anchor" href="#крок-3-перевіряите-результат-у-редакторі-а-не-читанням-yaml" aria-hidden="true" tabindex="-1">#</a>Крок 3: Перевіряйте результат у редакторі, а не читанням YAML</h2>
<p>Читання конвертованих файлів веде до впевнених і хибних висновків. Скорочені записи екземплярів префабів виглядають як втрата, так само як камера, яка лише переїхала в префаб. Важливо те, що Unity будує в пам&#39;яті. Відкрийте кожну конвертовану сцену й порівняйте її камери з дампом оригіналу з кроку 1.</p>
<p>В Onager після конвертації всі 75 сцен мали ті самі камери в тому самому порядку. Втратилося лише те, чого не знає жоден сучасний редактор: 569 GUITexture, 179 GUIText і старі системи частинок, які Unity прибрала у 2018 році.</p>
<h2 id="крок-4-unityscript-на-c-з-тим-самим-guid"><a class="heading-anchor" href="#крок-4-unityscript-на-c-з-тим-самим-guid" aria-hidden="true" tabindex="-1">#</a>Крок 4: UnityScript на C# з тим самим GUID</h2>
<p>Unity <a href="https://blog.unity.com/community/unityscripts-long-ride-off-into-the-sunset" target="_blank" rel="noopener">поступово прибирала UnityScript починаючи з версії 2017.2</a>, тож ручний порт на C# є єдиним шляхом. Сцени посилаються на скрипти через GUID, унікальний ідентифікатор, який Unity зберігає в meta-файлі кожного асета, а значення полів зберігають за назвою поля. Якщо дотриматися цього під час порту, усі посилання під&#39;єднаються самі:</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">для кожного скрипту X на UnityScript:
  створи C#-скрипт із класом точно на ім&#x27;я X
  скопіюй GUID з meta-файлу старого скрипту до meta-файлу нового
  публічні поля: та сама назва, той самий тип, навіть з одруківками
  нічого не перейменовуй, поки проєкт не працює</code></pre>
<p>В Onager так після порту всіх 126 скриптів під&#39;єдналися 12 935 компонентів у 73 сценах, і всі значення, задані в Inspector, залишилися.</p>
<p>Текстовим пошуком у проєкті не з&#39;ясувати, які скрипти використовуються. Бінарний префаб зберігає GUID як байти, а не як текст. В Onager так двадцять один скрипт здавався невикористаним, і всі вони були живі. Остерігайтеся й залишків попередніх спроб порту: один префаб дерев&#39;яного блоку вказував на два скрипти з такої спроби, і лише через нього бракувало 1 140 компонентів. Якщо в бінарному файлі треба змінити GUID, замініть його прямо в байтах, шістнадцять на шістнадцять, щоб файл не зсунувся.</p>
<h2 id="крок-5-відновіть-hud-з-оригінальних-бінарних-сцен"><a class="heading-anchor" href="#крок-5-відновіть-hud-з-оригінальних-бінарних-сцен" aria-hidden="true" tabindex="-1">#</a>Крок 5: Відновіть HUD з оригінальних бінарних сцен</h2>
<p>GUITexture і GUIText в Unity 6 немає, і разом з ними зникає весь HUD, побудований на них. Малювати його заново не потрібно. binary2text читає оригінальні бінарні сцени з історії git незалежно від того, чи існують ще класи. Витягніть з них кожне зображення й текст HUD з текстурою, кольором, позицією й вирівнюванням і скриптом поверніть їх на ті самі об&#39;єкти в сценах. В Onager це було 577 зображень і 180 текстів.</p>
<p>Так рятуються й тексти, яких більше ніде немає. В Onager це було 111 текстів: усі підписи туторіалів і увесь екран з титрами. Пишучи HUD з нуля, ви мусили б їх вигадати. Переносячи кольори, врахуйте те, про що мовчить документація: старий GUITexture подвоював колір під час малювання. Усі 577 зображень мали колір рівно 0,5, тобто сірий означав білий.</p>
<h2 id="після-конвертаціі-замініть-старі-обхідні-рішення"><a class="heading-anchor" href="#після-конвертаціі-замініть-старі-обхідні-рішення" aria-hidden="true" tabindex="-1">#</a>Після конвертації: замініть старі обхідні рішення</h2>
<p>Старі проєкти часто обходили обмеження тодішньої Unity, і ці обхідні рішення варто переробити після конвертації:</p>
<ul>
<li><strong>Відображення в підлозі.</strong> Onager мав блискучу підлогу без справжнього відображення. Під підлогою стояв другий, дзеркальний світ: копія кожного об&#39;єкта, позицію якої скрипт кожен третій кадр копіював з оригіналу, в одному рівні від 50 до 70 клонів. Замінити це можна однією додатковою камерою, яка малює відображення з половинною роздільною здатністю. Тоді відбиваються також частинки й уламки, чого клонований світ не вмів.</li>
<li><strong>Прицілювання.</strong> Якщо попередній перегляд траєкторії передбачає, куди пошле постріл сила, додана на першому фізичному кроці, постріл падає не там, де мітка. Надійніше обчислити швидкість один раз, намалювати перегляд саме з неї й призначити її пострілу напряму.</li>
<li><strong>Продуктивність.</strong> Вимірюйте на слабкому телефоні. На Motorola G52 Onager падав з 20 до 6 fps лише через шейдер стін, більше, ніж роздільність, тіні й відображення разом. Тому на низькій і середній деталізації стіни мають простіший матеріал.</li>
</ul>
<figure class="post-figure post-figure-video"><video class="post-video" muted loop playsinline controls preload="none" poster="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/gravity-shot.jpg" width="720" height="720" data-lazy-video aria-label="Гравітаційний постріл у перенесеному Onager: куля піднімає блоки, навколо неї вихор і синє світло."><source src="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/gravity-shot.mp4" type="video/mp4" /></video><figcaption>Гравітаційний постріл у перенесеному Onager: куля піднімає блоки, навколо неї вихор і синє світло.</figcaption></figure>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/onager/1-aim.jpg" alt="" width="1600" height="736" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Спробуйте</p><h2 class="app-box-title">Onager</h2><p>3D фізична головоломка з катапультою: 60 рівнів руйнування. Фізика, дизайн рівнів і нотатки до релізів.</p><div class="app-box-links"><a class="btn btn-primary" href="https://smallwater.eu/onager-ios" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="onager" data-ga-destination="/onager-ios" data-ga-app="onager" data-ga-store="app_store">App Store</a><a class="btn btn-ghost" href="https://smallwater.eu/onager-android" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="onager" data-ga-destination="/onager-android" data-ga-app="onager" data-ga-store="google_play">Google Play</a></div></div></aside>
<figure class="post-figure post-figure-video"><video class="post-video" muted loop playsinline controls preload="none" poster="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/rainbow-shot.jpg" width="720" height="720" data-lazy-video aria-label="Райдужний постріл: білі точки виходять просто з поверхні влученого блоку."><source src="https://smallwater.eu/blog/unity-4-to-unity-6-upgrade/rainbow-shot.mp4" type="video/mp4" /></video><figcaption>Райдужний постріл: білі точки виходять просто з поверхні влученого блоку.</figcaption></figure>
<p>Якщо ви розв&#39;язуєте фізику тисяч об&#39;єктів у сучасній Unity, подивіться також на <a href="https://smallwater.eu/ua/blog/fake-ragdoll-unity-dots/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/unity-4-to-unity-6-upgrade/" data-ga-destination="/ua/blog/fake-ragdoll-unity-dots/" data-ga-placement="inline">фейковий ragdoll для тисяч ворогів в Unity DOTS</a>. Як після перенесення додати старій 2D-грі glow і 2D-світло в URP, описано в <a href="https://smallwater.eu/ua/blog/dreamica-remaster-unity-urp-glow/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/unity-4-to-unity-6-upgrade/" data-ga-destination="/ua/blog/dreamica-remaster-unity-urp-glow/" data-ga-placement="inline">окремій інструкції</a>.</p>
<h2 id="чого-цеи-підхід-не-розв-язує"><a class="heading-anchor" href="#чого-цеи-підхід-не-розв-язує" aria-hidden="true" tabindex="-1">#</a>Чого цей підхід не розв&#39;язує</h2>
<p>Компоненти, яких сучасна Unity не знає, не під&#39;єднаються. GUITexture і GUIText треба відновити, як у кроці 5, старі системи частинок втрачаються. Назви класів і полів залишаться точно такими, як в оригінальному проєкті, з одруківками й тією мовою, якою їх колись хтось написав. Сцени прив&#39;язуються до них за назвою, тож перейменовувати має сенс лише через редактор, який сам перепише посилання.</p>
<p>Якщо у вас є старий проєкт на Unity, який треба перенести на сучасну версію, <a href="https://smallwater.eu/ua/#kontakt">напишіть нам</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Як керувати ордою з тисяч ворогів в Unity DOTS</title>
      <link>https://smallwater.eu/ua/blog/unity-dots-horde-navigation/</link>
      <guid isPermaLink="true">https://smallwater.eu/ua/blog/unity-dots-horde-navigation/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Tower Defense</category>
      <description>Посібник з руху тисяч ворогів в Unity DOTS: flow field, відштовхування від сусідів і стін, вартість за щільністю натовпу, затори у вузьких проходах.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/cover.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/cover.jpg" alt="Орда ворогів у вигляді світлих точок обтікає перешкоди до цілі, під нею стрілки flow field і червона карта щільності натовпу" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Зміст"><p class="post-toc-title">Зміст</p><ol><li><a href="#крок-1-flow-field-замість-шляху-для-кожного" data-ga="blog_toc_clicked" data-ga-heading-id="крок-1-flow-field-замість-шляху-для-кожного">Крок 1: Flow field замість шляху для кожного</a></li><li><a href="#крок-2-окреме-поле-для-кожного-розміру-ворога" data-ga="blog_toc_clicked" data-ga-heading-id="крок-2-окреме-поле-для-кожного-розміру-ворога">Крок 2: Окреме поле для кожного розміру ворога</a></li><li><a href="#крок-3-відштовхування-від-сусідів" data-ga="blog_toc_clicked" data-ga-heading-id="крок-3-відштовхування-від-сусідів">Крок 3: Відштовхування від сусідів</a></li><li><a href="#крок-4-відштовхування-від-стін" data-ga="blog_toc_clicked" data-ga-heading-id="крок-4-відштовхування-від-стін">Крок 4: Відштовхування від стін</a></li><li><a href="#крок-5-вартість-за-щільністю-та-сповільнення" data-ga="blog_toc_clicked" data-ga-heading-id="крок-5-вартість-за-щільністю-та-сповільнення">Крок 5: Вартість за щільністю та сповільнення</a></li><li><a href="#крок-6-затор-у-вузькому-проході-чекати-а-не-тиснути" data-ga="blog_toc_clicked" data-ga-heading-id="крок-6-затор-у-вузькому-проході-чекати-а-не-тиснути">Крок 6: Затор у вузькому проході: чекати, а не тиснути</a></li><li><a href="#крок-7-обгін-великих-ворогів" data-ga="blog_toc_clicked" data-ga-heading-id="крок-7-обгін-великих-ворогів">Крок 7: Обгін великих ворогів</a></li><li><a href="#крок-8-слоти-біля-цілі-атаки" data-ga="blog_toc_clicked" data-ga-heading-id="крок-8-слоти-біля-цілі-атаки">Крок 8: Слоти біля цілі атаки</a></li><li><a href="#продуктивність-симуляціі-та-рендерингу" data-ga="blog_toc_clicked" data-ga-heading-id="продуктивність-симуляціі-та-рендерингу">Продуктивність симуляції та рендерингу</a></li><li><a href="#обмеження-підходу" data-ga="blog_toc_clicked" data-ga-heading-id="обмеження-підходу">Обмеження підходу</a></li></ol></nav><p>Орду з тисяч ворогів в Unity DOTS ведіть одним flow field для всієї мапи та кількома дешевими локальними правилами: відштовхування від сусідів і від стін, вартість шляху залежно від щільності натовпу та стан «чекаю» в заторі. В Unity Editor на MacBook з M3 Pro це витримує близько 9 600 ворогів одночасно при 90 FPS.</p>
<p>Проблема виникає в tower defense, стратегіях і survival іграх, де до цілі одночасно йдуть тисячі юнітів. Класичний пошук шляху для кожного окремо надто дорогий, а без локальних правил натовп перекривається, липне до стін і застрягає у вузьких проходах. Unity DOTS (Data-Oriented Technology Stack, тобто Entities, ECS і Job System) дає продуктивність для тисяч сутностей, але саму навігацію не розв&#39;язує. Описаний нижче підхід ми використовуємо в нашій tower defense грі для ПК (вона є в нашому <a href="https://smallwater.eu/ua/#referencie">портфоліо</a>). Зображення взяті зі спрощеної 2D-симуляції, що працює за тими самими правилами, це не кадри з гри.</p>
<h2 id="крок-1-flow-field-замість-шляху-для-кожного"><a class="heading-anchor" href="#крок-1-flow-field-замість-шляху-для-кожного" aria-hidden="true" tabindex="-1">#</a>Крок 1: Flow field замість шляху для кожного</h2>
<p>Flow field це сітка на всю мапу, у якій кожна клітинка знає, в якому напрямку від неї йти до цілі. Ворог не шукає шлях, а лише зчитує напрямок у місці, де стоїть. Вартість однакова для 10 і для 10 000 ворогів.</p>
<p>Поділіть мапу на клітинки по 1 м (наш тестовий рівень має 128 × 128) і для кожної цілі один раз порахуйте відстань від кожної клітинки до цілі. Статичне поле достатньо порахувати в редакторі під час підготовки рівня і зберегти в асеті.</p>
<p>Найпростіший варіант це Дейкстра через 8 сусідів і в кожній клітинці один із 8 напрямків. Це працює, але вороги ходять по діагоналях і рядком липнуть до стін.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">8 напрямків</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/flow-8dir.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/flow-8dir.png" alt="Flow field з вісьмома напрямками, шляхи ворогів ідуть по діагоналях і липнуть до краю перешкоди" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Плавне поле</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/flow-smooth.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/flow-smooth.png" alt="Плавний flow field, шляхи дугою сходяться навколо кута перешкоди з відступом від стіни" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div></div><figcaption>Та сама мапа, шляхи поодиноких ворогів. Ліворуч 8 напрямків, праворуч плавне поле з надбавкою біля стін.</figcaption></figure>
<p>Кращий результат дає плавне поле. Відстань порахуйте розв&#39;язанням <a href="https://en.wikipedia.org/wiki/Eikonal_equation" target="_blank" rel="noopener">рівняння ейконалу</a> (рівняння поширення хвильового фронту, розв&#39;язком якого є найкоротша відстань у неперервному просторі, а не лише вздовж ребер сітки) методом fast sweeping, який багаторазово проходить сітку з усіх чотирьох кутів. Тоді напрямок це не напрямок клітинки, а градієнт відстані в точному місці ворога. До вартості клітинки додайте надбавку за близькість до стіни, тож поле саме тримає натовп трохи далі від перешкод.</p>
<h2 id="крок-2-окреме-поле-для-кожного-розміру-ворога"><a class="heading-anchor" href="#крок-2-окреме-поле-для-кожного-розміру-ворога" aria-hidden="true" tabindex="-1">#</a>Крок 2: Окреме поле для кожного розміру ворога</h2>
<p>Великий ворог вміщається не всюди, куди малий. Запровадьте класи розміру: кожен має власну прохідність (клітинка має бути достатньо далеко від стіни) і власне поле для кожної цілі. Тримайте класи як дані рівня, а не в коді. У нас їх три.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/size-classes.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/size-classes.png" alt="Мапа з вузьким коридором: малі вороги йдуть коридором, більші помаранчеві обходять його знизу" width="1792" height="1008" loading="lazy" decoding="async" /></picture><figcaption>Малі вороги йдуть вузьким коридором, великі (помаранчеві) туди не вміщаються й обходять його.</figcaption></figure>
<h2 id="крок-3-відштовхування-від-сусідів"><a class="heading-anchor" href="#крок-3-відштовхування-від-сусідів" aria-hidden="true" tabindex="-1">#</a>Крок 3: Відштовхування від сусідів</h2>
<p>Flow field дає напрямок, але нічого не знає про сусідів. Основа це спрощена модель boids (класичні правила зграї): кожен ворог відштовхується від сусідів у радіусі 0,9 м. Сусідів шукайте в spatial hash, тобто в просторовій сітці, де кожна клітинка тримає список юнітів у ній. Будуйте hash щотіку заново й сортуйте, щоб порядок був детермінованим.</p>
<p>Самого відштовхування замало. При 3 000 ворогах у 83,7 % із них найближчий сусід був ближче ніж 0,8 м, а найменша відстань становила 4 см, тобто двоє ворогів майже один в одному.</p>
<p>Більша вага відштовхування проблему не розв&#39;язує. При вазі 6 замість 1,5 частка надто тісних упала до 54,6 %, при 12 до 44,3 %, при 24 далі нічого (48,2 %). До того ж натовп починає підстрибувати, бо необмежені поштовхи від багатьох сусідів додаються одночасно.</p>
<p>Допомагає позиційна корекція. Після руху розсуньте кожну пару, що перекривається, кожного на половину перекриття, а зсув за тік обмежте 0,15 м. Без цього обмеження корекція стрибає так само, як висока вага. З ним надто тісних залишається 60,8 %, але найменша відстань зростає до 28 см і натовп не підстрибує.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Лише відштовхування</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/overlap-before.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/overlap-before.png" alt="Натовп біля вузького проходу, велика частина ворогів червона, вони перекриваються" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Корекція перекриття</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/overlap-after.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/overlap-after.png" alt="Той самий натовп із корекцією перекриття, червоні залишилися лише безпосередньо перед проходом" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div></div><figcaption>Червоні перекриваються із сусідом більш ніж на 20 см. На відкритому місці корекція допомагає, перед вузьким проходом ні.</figcaption></figure>
<h2 id="крок-4-відштовхування-від-стін"><a class="heading-anchor" href="#крок-4-відштовхування-від-стін" aria-hidden="true" tabindex="-1">#</a>Крок 4: Відштовхування від стін</h2>
<p>Біля стіни корекція між ворогами не спрацьовує. Рух у стіну заборонено (зсув розкладається по осях, а складова в стіну відкидається), тож притиснутому ворогу нікуди ухилитися, і корекція від сусіда губиться. Із 938 ворогів, що перекривалися, ненульовий поштовх від стіни мали лише 35.</p>
<p>Тому додайте відштовхування від найближчих непрохідних клітинок у 4 напрямках і по діагоналі. Дві помилки, яких можна уникнути:</p>
<ul>
<li><strong>Внутрішній кут.</strong> Поштовхи від обох країв і від кута додаються й відкидають ворога. Додавайте поштовх від кута лише на зовнішньому куті.</li>
<li><strong>Справжні стіни.</strong> Відштовхуйте від справжніх стін, а не від прохідності класу розміру. Для великого ворога «стіна» починається вже за метр до справжньої. На вході в коридор поштовх від кута так урівноважив рух великого ворога вперед, і вся група за ним зупинилася.</li>
</ul>
<h2 id="крок-5-вартість-за-щільністю-та-сповільнення"><a class="heading-anchor" href="#крок-5-вартість-за-щільністю-та-сповільнення" aria-hidden="true" tabindex="-1">#</a>Крок 5: Вартість за щільністю та сповільнення</h2>
<p>Навіть із плавним полем увесь натовп іде найкоротшим шляхом впритул навколо кута, незалежно від того, скільки ворогів там уже є. Рішення пропонує <a href="https://grail.cs.washington.edu/projects/continuum-crowds" target="_blank" rel="noopener">Continuum Crowds</a> (Treuille та ін., 2006): вартість клітинки зростає зі щільністю натовпу. Раз на 10 тіків порахуйте ворогів у клітинках, згладьте результат і перерахуйте поле однієї цілі. Біля переповненого кута поле тоді відправляє ворога в обхід, через вільніші клітинки.</p>
<figure class="post-compare"><div class="compare-grid"><div class="compare-item"><span class="compare-label">Без щільності</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/density-off.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/density-off.png" alt="Натовп обходить перешкоду впритул до кута вузьким потоком, червона карта щільності зосереджена" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div><div class="compare-item"><span class="compare-label">Вартість за щільністю</span><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/density-on.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/density-on.png" alt="Натовп обходить перешкоду широкою дугою, щільність розподілена" width="1792" height="1008" loading="lazy" decoding="async" /></picture></div></div><figcaption>Той самий натовп навколо того самого кута. Червоне це згладжена щільність натовпу.</figcaption></figure>
<p>Друга половина це сповільнення. Ворог у щільній клітинці рухається повільніше, щонайменше на 15 % швидкості. На 2 250 ворогах вони в розріджених клітинках ішли в середньому на 94 % швидкості, у помірно щільних на 71 %, а в заторі на 20 %. Нехай сповільнення починається раніше: з порогом вільної щільності 0,6 ворога на клітинку замість 1 частка надто тісних у вузькому проході впала до 36,7 %.</p>
<p>Практична порада: масиви, що створюються лише під час виконання (наприклад, карта щільності), виділяйте раніше, ніж їх уперше використає job. Job із ще не створеним масивом обвалює Unity Editor на нативному рівні, без винятку.</p>
<h2 id="крок-6-затор-у-вузькому-проході-чекати-а-не-тиснути"><a class="heading-anchor" href="#крок-6-затор-у-вузькому-проході-чекати-а-не-тиснути" aria-hidden="true" tabindex="-1">#</a>Крок 6: Затор у вузькому проході: чекати, а не тиснути</h2>
<p>Коли натовпу нікуди розширитися, залишається вузький прохід. Найпростіша ідея це відкинути складову швидкості в бік сусіда, якого ворог торкається. Задні тоді стоять замість того, щоб ухилитися, і підтримують затор.</p>
<p>Ухилитися вбік, наприклад на 35° у бік із меншою кількістю сусідів, краще, але натовп починає мерехтіти. Згладжування повороту й нижча швидкість обертання (180 замість 360 °/с) лише приглушують симптом. Причина в іншому: при врівноваженій щільності ворог щотіку обирає інший бік. Допомагає гістерезис, тобто перевага попереднього рішення: бік із минулого тіку отримує під час порівняння знижку в 3 сусіди.</p>
<p>Остаточне рішення поєднує контактне обмеження і три стани: іде, обходить, чекає. Складова швидкості в сусіда, якого ворог торкається, відкидається, бічна залишається. Хто не має ні вільного боку, ні місця попереду, чекає і не тисне.</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">для кожного ворога, щотіку:
    швидкість = напрямок із flow field × швидкість × сповільнення за щільністю
    швидкість += відштовхування від сусідів
    якщо попереду торкається сусіда:
        якщо є вільний бік: обходь (бік із гістерезисом)
        інакше: чекай
    відкинь складову швидкості в сусідів, яких торкаєшся
    зрушся, складову в стіну відкинь
потім для всіх:
    розсунь пари, що перекриваються (половина перекриття, щонайбільше 0,15 м)
    відштовхни від стін</code></pre>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/crowd-states.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/crowd-states.png" alt="Натовп перед вузьким проходом, білі точки йдуть, червоні чекають, за проходом іде ряд із проміжками" width="1792" height="1008" loading="lazy" decoding="async" /></picture><figcaption>Перед вузьким проходом: білі йдуть, червоні чекають. За проходом іде ряд із проміжками.</figcaption></figure>
<p>Зверніть увагу на три речі:</p>
<ul>
<li><strong>Терпіння.</strong> Коли двоє ворогів зустрічаються лоб у лоб, нехай чекає той, у кого вищий ID. Ланцюг сусідів може блокувати його тисячі тіків, тому після ліміту очікування він пробує аварійно ухилитися.</li>
<li><strong>Нульова швидкість.</strong> Ворог із нульовою бажаною швидкістю ніколи не виходить зі стану «чекає» і після хвилі залишається стояти на відкритому місці. Хто не хоче рухатися, має завжди бути в стані «іде».</li>
<li><strong>Вузький конус.</strong> Шукайте сусіда попереду у вузькому конусі з короткою пробою. При 45° налаштування відправляло в очікування чи обхід 55 до 60 % натовпу навіть на відкритому місці, при 30° і коротшій пробі 41 %.</li>
</ul>
<h2 id="крок-7-обгін-великих-ворогів"><a class="heading-anchor" href="#крок-7-обгін-великих-ворогів" aria-hidden="true" tabindex="-1">#</a>Крок 7: Обгін великих ворогів</h2>
<p>Типи ворогів різного розміру та швидкості додають нових випадків. Наш великий повільний ворог має радіус у натовпі 1,25 м і йде зі швидкістю 1,1 м/с. Швидкий ворог позаду не міг його обігнати: бічні проби навскіс уперед завжди були ближче до великого, ніж сума радіусів, обидва боки виявлялися зайнятими, і швидкий чекав. Виправлення: сусіда, якого ворог обходить, проба враховує лише з його власного боку. Сусід точно попереду не блокує жодного боку.</p>
<p>Великий ворог, навпаки, відкидає малих зі свого шляху вбік, у секторі ±45° перед собою. Не відкидайте тих, хто рухається в його напрямку швидше, ніж хоче йти він сам, інакше він відкине й тих, хто саме його обганяє.</p>
<h2 id="крок-8-слоти-біля-цілі-атаки"><a class="heading-anchor" href="#крок-8-слоти-біля-цілі-атаки" aria-hidden="true" tabindex="-1">#</a>Крок 8: Слоти біля цілі атаки</h2>
<p>Якщо вороги атакують зблизька, їм потрібне місце навколо цілі. Наша вежа має 24 слоти по периметру основи 5 × 5 м, кожен ворог займає один, а решта чекає в черзі.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/unity-dots-horde-navigation/tower-slots.webp" /><img src="https://smallwater.eu/blog/unity-dots-horde-navigation/tower-slots.png" alt="Вежа з 24 слотами по периметру, більшість зайняті, жовтий шлях обходить вежу дугою до вільного слота" width="1408" height="960" loading="lazy" decoding="async" /></picture><figcaption>Зайняті слоти атакують, вільні позначено пунктиром. Новий ворог обходить вежу по дузі до вільного слота, за заповненим боком чекає черга.</figcaption></figure>
<ul>
<li><strong>Обхід по колу.</strong> Якщо пропонувати слот лише тоді, коли до нього веде пряма лінія, задні слоти залишаються порожніми, бо спереду лінія до них перетинає вежу. Нехай ворог обходить вежу по дузі до вільного слота.</li>
<li><strong>Без витіснення.</strong> Не витісняйте ворога, що йде до слота, на користь іншого претендента, це запускає ланцюг витіснень.</li>
<li><strong>Без відштовхування біля слота.</strong> Ворога, що підходить поруч із зайнятим слотом, відштовхує сусід, і він опиняється за 13 см від цілі або зовсім осторонь. Вороги в слоті та в межах 0,5 м від нього не відштовхують одне одного.</li>
</ul>
<h2 id="продуктивність-симуляціі-та-рендерингу"><a class="heading-anchor" href="#продуктивність-симуляціі-та-рендерингу" aria-hidden="true" tabindex="-1">#</a>Продуктивність симуляції та рендерингу</h2>
<p>Benchmark в Unity Editor (M3 Pro, близько 9 600 простих ворогів, два вузькі проходи) дав 90 FPS при 1440p і 100 при 1080p. Роздільна здатність майже не змінює FPS, а прискорення часу змінює: при 10× падає до 20 FPS, бо на один кадр припадає десять кроків симуляції. На звичайній швидкості симуляція має запас.</p>
<p>З детальною моделлю ворога замість капсули вузьке місце переміщується в рендеринг:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Варіант</th>
<th align="right">Одночасно</th>
<th align="right">Трикутників на модель</th>
<th align="right">Медіана FPS</th>
</tr>
</thead>
<tbody><tr>
<td>Капсула</td>
<td align="right">2 612</td>
<td align="right">844</td>
<td align="right">197</td>
</tr>
<tr>
<td>Детальна модель, з тінями</td>
<td align="right">2 976</td>
<td align="right">3 578</td>
<td align="right">48</td>
</tr>
<tr>
<td>Детальна модель, без тіней</td>
<td align="right">2 976</td>
<td align="right">3 578</td>
<td align="right">85</td>
</tr>
<tr>
<td>Спрощена модель, з тінями</td>
<td align="right">2 945</td>
<td align="right">878</td>
<td align="right">335</td>
</tr>
</tbody></table>
</div>
<p>У симуляції окупаються три речі:</p>
<ul>
<li><strong>Два проходи по сусідах.</strong> Дешевий (дотики, блокування) працює завжди, дорогий (бічні проби, щільність боків) лише для заблокованих, яких у вільному потоці меншість. Порівнюйте відстані в квадраті, без кореня.</li>
<li><strong>Просте сортування.</strong> Синхронне сортування spatial hash тривало при 3 000, 10 000, 20 000 і 50 000 записів 0,37, 1,49, 3,16 і 8,46 мс, версія в job 0,18, 0,93, 3,12 і 15,53 мс. Job не є стабільно швидшим, простого сортування достатньо.</li>
<li><strong>Hash стану.</strong> На тіках 600 і 1800 рахуйте hash позицій і здоров&#39;я. Рефакторинг має дати той самий hash, зміна поведінки його змінює, і ви можете пояснити чому. Це працює завдяки детермінованому порядку в spatial hash.</li>
</ul>
<h2 id="обмеження-підходу"><a class="heading-anchor" href="#обмеження-підходу" aria-hidden="true" tabindex="-1">#</a>Обмеження підходу</h2>
<ul>
<li>Два повні зустрічні натовпи в одному вузькому проході застрягають назавжди. Це обмеження моделі без фізики.</li>
<li>Цифри отримано в Unity Editor на M3 Pro. Вимірювань на Steam Deck і в справжньому build у нас поки немає, лише оцінка з публічних benchmark процесорів.</li>
</ul>
<p>Якщо хочете спробувати власний натовп, <a href="https://docs.unity3d.com/Packages/com.unity.entities@6.5/manual/" target="_blank" rel="noopener">документація Unity Entities</a> це добрий початок. Схожий шлях від найпростішого рішення до робочого описано і в нашій статті про <a href="https://smallwater.eu/ua/blog/sudoku-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/unity-dots-horde-navigation/" data-ga-destination="/ua/blog/sudoku-generator/" data-ga-placement="inline">генератор судоку</a>. Як відкинути ворогів у такому натовпі вибухом без дорогого ragdoll для кожного, описано в інструкції <a href="https://smallwater.eu/ua/blog/fake-ragdoll-unity-dots/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/unity-dots-horde-navigation/" data-ga-destination="/ua/blog/fake-ragdoll-unity-dots/" data-ga-placement="inline">як зробити fake ragdoll в Unity DOTS</a>. Якщо ви працюєте над схожою симуляцією в Unity і хочете допомоги, <a href="https://smallwater.eu/ua/#kontakt">напишіть нам</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Як згенерувати пошук слів із зашифрованим словом</title>
      <link>https://smallwater.eu/ua/blog/word-search-generator/</link>
      <guid isPermaLink="true">https://smallwater.eu/ua/blog/word-search-generator/</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <category>Кросворди та головоломки</category>
      <description>Генератор пошуку слів із зашифрованим словом крок за кроком: словник, довжина слів, заповнення сітки без backtracking і успішність залежно від розміру сітки.</description>
      <content:encoded><![CDATA[<picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/cover.webp" /><img src="https://smallwater.eu/blog/word-search-generator/cover.jpg" alt="Пошук слів 10×10 з кольоровими позначками слів і зашифрованим словом у коралових клітинках" width="1200" height="630" fetchpriority="high" decoding="async" /></picture><nav class="post-toc" aria-label="Зміст"><p class="post-toc-title">Зміст</p><ol><li><a href="#правила-сітки" data-ga="blog_toc_clicked" data-ga-heading-id="правила-сітки">Правила сітки</a></li><li><a href="#крок-1-підготуите-словник" data-ga="blog_toc_clicked" data-ga-heading-id="крок-1-підготуите-словник">Крок 1: Підготуйте словник</a></li><li><a href="#крок-2-виберіть-зашифроване-слово" data-ga="blog_toc_clicked" data-ga-heading-id="крок-2-виберіть-зашифроване-слово">Крок 2: Виберіть зашифроване слово</a></li><li><a href="#чому-не-варто-починати-із-зашифрованого-слова" data-ga="blog_toc_clicked" data-ga-heading-id="чому-не-варто-починати-із-зашифрованого-слова">Чому не варто починати із зашифрованого слова</a></li><li><a href="#крок-3-заповнюите-сітку-доки-не-залишиться-рівно-n-дірок" data-ga="blog_toc_clicked" data-ga-heading-id="крок-3-заповнюите-сітку-доки-не-залишиться-рівно-n-дірок">Крок 3: Заповнюйте сітку, доки не залишиться рівно N дірок</a></li><li><a href="#крок-4-вибираите-довжину-слова-з-розумом" data-ga="blog_toc_clicked" data-ga-heading-id="крок-4-вибираите-довжину-слова-з-розумом">Крок 4: Вибирайте довжину слова з розумом</a></li><li><a href="#крок-5-залиите-зашифроване-слово-в-дірки" data-ga="blog_toc_clicked" data-ga-heading-id="крок-5-залиите-зашифроване-слово-в-дірки">Крок 5: Залийте зашифроване слово в дірки</a></li><li><a href="#коли-сітка-не-вдалася" data-ga="blog_toc_clicked" data-ga-heading-id="коли-сітка-не-вдалася">Коли сітка не вдалася</a></li><li><a href="#успішність-і-швидкість-залежно-від-розміру-сітки" data-ga="blog_toc_clicked" data-ga-heading-id="успішність-і-швидкість-залежно-від-розміру-сітки">Успішність і швидкість залежно від розміру сітки</a></li><li><a href="#порада-та-сама-сітка-при-кожному-запуску" data-ga="blog_toc_clicked" data-ga-heading-id="порада-та-сама-сітка-при-кожному-запуску">Порада: та сама сітка при кожному запуску</a></li></ol></nav><p>Щоб згенерувати пошук слів із зашифрованим словом, кладіть у порожню сітку випадкові слова зі словника, доки не залишиться рівно стільки вільних клітинок, скільки літер у зашифрованому слові. Потім заповніть ці клітинки зашифрованим словом зліва направо і згори вниз. На телефоні це займає кілька мілісекунд і не потребує backtracking, тобто повернення до попередніх рішень, коли генератор застрягає.</p>
<p>Пошук слів (у Словаччині цю головоломку називають osemsmerovka, «восьминапрямка») це сітка літер, у якій гравець шукає і викреслює слова зі списку. Зашифроване слово складається з літер, що залишаються невикресленими. Тож генератор мусить побудувати повністю заповнену сітку, у якій після викреслення слів залишиться рівно зашифроване слово. Описаним нижче способом генерується пошук слів у нашому застосунку з кросвордами та пошуком слів (<a href="https://smallwater.eu/crosswords-ios">App Store</a>, <a href="https://smallwater.eu/crosswords-android">Google Play</a>).</p>
<h2 id="правила-сітки"><a class="heading-anchor" href="#правила-сітки" aria-hidden="true" tabindex="-1">#</a>Правила сітки</h2>
<p>Перш ніж почати, визначте, яким умовам має відповідати готова сітка. У нашому застосунку:</p>
<ul>
<li>Слова йдуть у восьми напрямках: горизонтально, вертикально й по обох діагоналях, кожним напрямком також навпаки.</li>
<li>Два слова можуть мати щонайбільше одну спільну літеру.</li>
<li>Жодне слово не може проходити через клітинку зашифрованого слова.</li>
<li>Сітка має бути повною. Порожня клітинка це помилка.</li>
<li>Зашифроване слово читається зліва направо і згори вниз з усіх невикреслених літер.</li>
</ul>
<h2 id="крок-1-підготуите-словник"><a class="heading-anchor" href="#крок-1-підготуите-словник" aria-hidden="true" tabindex="-1">#</a>Крок 1: Підготуйте словник</h2>
<p>Один раз на початку розкладіть словник на групи за довжиною слова. Тоді вибір випадкового слова заданої довжини це один випадковий індекс. Якщо для кожного слова фільтрувати весь словник, генерація помітно сповільниться, адже для однієї сітки пробуються сотні слів.</p>
<p>Наш словацький словник містить 16 713 слів довжиною від 3 до 21 літери, найбільше семи- і восьмилітерних. Кілька практичних порад:</p>
<ul>
<li><strong>Залиште у словнику власні назви та абревіатури.</strong> Тоді в сітці з&#39;являться слова на кшталт HOUSTON чи MAINE. Без коротких і «дивних» слів сітку не вдасться заповнити достатньо щільно.</li>
<li><strong>Фільтруйте лайку окремим списком</strong>, який можна вимкнути, якщо гравець хоче вміст для дорослих.</li>
<li><strong>Якщо мов кілька, завантажуйте лише ту, якою зараз грають.</strong> Англійський словник має понад 414 000 слів. Якщо під час запуску застосунку завантажити всі чотири мови одразу, слабший телефон зависне аж на 11 секунд.</li>
</ul>
<h2 id="крок-2-виберіть-зашифроване-слово"><a class="heading-anchor" href="#крок-2-виберіть-зашифроване-слово" aria-hidden="true" tabindex="-1">#</a>Крок 2: Виберіть зашифроване слово</h2>
<p>Зашифроване слово має містити щонайменше стільки літер, скільки клітинок у стороні сітки, і щонайбільше на чотири більше. Коротше загубиться в сітці, довше потребуватиме забагато дірок.</p>
<p>У нашому застосунку зашифроване слово це кінцівка жарту. Гравець викреслює слова і з решти літер читає, чим закінчується жарт. Ми складаємо його з кінця жарту: додаємо цілі слова з кінця, доки довжина не потрапить у цей діапазон.</p>
<p>На зображеннях нижче англійський жарт «Two snakes walk into a bar. Turns out they were lizards.» (Дві змії заходять у бар. Виявляється, це були ящірки.) на сітці 10×10. Зашифроване слово WERELIZARDS, 11 літер. Слова на зображеннях узято з англійського словника, звуженого до поширених слів, щоб їх можна було прочитати.</p>
<h2 id="чому-не-варто-починати-із-зашифрованого-слова"><a class="heading-anchor" href="#чому-не-варто-починати-із-зашифрованого-слова" aria-hidden="true" tabindex="-1">#</a>Чому не варто починати із зашифрованого слова</h2>
<p>Перша ідея: робити так, як робила б людина олівцем. Розкидати літери зашифрованого слова по випадкових клітинках у порядку читання, а потім будувати навколо них слова.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/v1-seed.webp" /><img src="https://smallwater.eu/blog/word-search-generator/v1-seed.png" alt="Порожня сітка 10×10 з одинадцятьма випадково розкиданими літерами зашифрованого слова" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Літери зашифрованого слова, розкидані по сітці ще до слів.</figcaption></figure>
<p>Виглядає розумно, доки не настає кінець. Генератор вибирає порожню клітинку, пробує провести через неї слово в усіх напрямках і зсувах, а коли сітка стає щільною, залишаються ізольовані дірки. Одну дірку між двома літерами зашифрованого слова не заповнить нічого, бо найкоротше слово має три літери, а через зашифроване слово проходити не можна.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/v1-stuck.webp" /><img src="https://smallwater.eu/blog/word-search-generator/v1-stuck.png" alt="Майже повна сітка з трьома порожніми клітинками, які вже не заповнити" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Після 10 000 спроб залишилися три дірки, які не заповнить жодне слово.</figcaption></figure>
<p>На вибірці з 200 сіток 10×10 цей підхід давав повну сітку лише у 28 % випадків, у середньому залишалося 1-2 дірки. Латати їх потім складно. Тому не ставте зашифроване слово в сітку, доки не готові слова.</p>
<h2 id="крок-3-заповнюите-сітку-доки-не-залишиться-рівно-n-дірок"><a class="heading-anchor" href="#крок-3-заповнюите-сітку-доки-не-залишиться-рівно-n-дірок" aria-hidden="true" tabindex="-1">#</a>Крок 3: Заповнюйте сітку, доки не залишиться рівно N дірок</h2>
<p>Заповнення це простий greedy algorithm: кожне слово кладеться, щойно вміщається, і більше не прибирається. Генератор пам&#39;ятає лише одне число: скільки клітинок ще порожні. Слово кладеться тільки тоді, коли після нього не залишиться менше порожніх клітинок, ніж літер у зашифрованому слові (N).</p>
<pre class="code-block" data-lang="text"><code class="hljs language-plaintext">порожні = рядки × стовпці
поки порожні &gt; N і спроб &lt; 2000:
    слово = випадкове слово вибраної довжини
    повторити щонайбільше 50 разів:
        початок = випадкова клітинка, напрямок = випадковий з 8
        якщо слово вміщається за правилами сітки:
            нові = кількість порожніх клітинок, які слово заповнить
            якщо порожні - нові &gt;= N:
                покласти слово
                порожні = порожні - нові
                перейти до наступного слова
якщо порожні != N: викинути сітку й почати знову</code></pre>
<p>Перші слова лягають у порожню сітку вільно:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/step-1.webp" /><img src="https://smallwater.eu/blog/word-search-generator/step-1.png" alt="Сітка з першими трьома словами: одне горизонтально і два вертикально" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Після трьох слів. Кожне слово має свій колір.</figcaption></figure>
<p>Що щільніша сітка, то частіше слова ділять літери. Це добре, бо кожна спільна літера означає на одну клітинку менше для заповнення:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/step-2.webp" /><img src="https://smallwater.eu/blog/word-search-generator/step-2.png" alt="Сітка з тринадцятьма словами, понад третина клітинок порожня" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Тринадцять слів, 40 порожніх клітинок.</figcaption></figure>
<p>Коли кількість порожніх клітинок падає до N, заповнення закінчується. Де саме ці дірки, неважливо. Вони можуть бути розкидані будь-де, бо зашифроване слово не мусить утворювати через них жодного шляху:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/step-3.webp" /><img src="https://smallwater.eu/blog/word-search-generator/step-3.png" alt="Сітка з двадцятьма двома словами і одинадцятьма порожніми клітинками з пунктирною рамкою" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Залишилося рівно 11 порожніх клітинок, стільки ж, скільки літер у WERELIZARDS.</figcaption></figure>
<h2 id="крок-4-вибираите-довжину-слова-з-розумом"><a class="heading-anchor" href="#крок-4-вибираите-довжину-слова-з-розумом" aria-hidden="true" tabindex="-1">#</a>Крок 4: Вибирайте довжину слова з розумом</h2>
<p>Довгі слова роблять головоломку цікавою, короткі допомагають її завершити. Тому вибирайте довжину з двох нормальних розподілів (нормально розподілені випадкові числа дає, наприклад, <a href="https://en.wikipedia.org/wiki/Box%E2%80%93Muller_transform" target="_blank" rel="noopener">перетворення Бокса-Мюллера</a>):</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Імовірність</th>
<th>Довжина слова</th>
</tr>
</thead>
<tbody><tr>
<td>50 %</td>
<td>близько 6 літер, стандартне відхилення 2</td>
</tr>
<tr>
<td>30 %</td>
<td>близько 5 літер, стандартне відхилення 1</td>
</tr>
<tr>
<td>20 %</td>
<td>3 літери</td>
</tr>
</tbody></table>
</div>
<p>Довжина завжди щонайменше 3 і щонайбільше сторона сітки. Додайте два правила, без яких генератор наприкінці застрягає:</p>
<ul>
<li><strong>Наприкінці задавайте точну довжину.</strong> Коли до цілі бракує 5 клітинок або менше, шукайте слово рівно такої довжини, якої бракує (щонайменше 3). Інакше генератор безкінечно пробуватиме шестилітерні слова, які вже не вміщаються.</li>
<li><strong>Видавайте трилітерні слова за нормою.</strong> Перші три спроби сітки не можуть містити жодного, спроби 4-6 одне, наступні три два і так далі. Трилітерні слова у словнику часто є абревіатурами на кшталт DNA чи CPU і в головоломці виглядають дешево. Дозволяйте їх, лише коли інакше не виходить.</li>
</ul>
<h2 id="крок-5-залиите-зашифроване-слово-в-дірки"><a class="heading-anchor" href="#крок-5-залиите-зашифроване-слово-в-дірки" aria-hidden="true" tabindex="-1">#</a>Крок 5: Залийте зашифроване слово в дірки</h2>
<p>Пройдіть сітку зліва направо і згори вниз і в кожну порожню клітинку запишіть наступну літеру зашифрованого слова. Оскільки дірок рівно N, слово вміщається без залишку.</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/step-4.webp" /><img src="https://smallwater.eu/blog/word-search-generator/step-4.png" alt="Та сама сітка, порожні клітинки заповнені літерами зашифрованого слова з номерами від 1 до 11" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Літери зашифрованого слова в порядку читання. Гравець знаходить їх як останні невикреслені.</figcaption></figure>
<p>Гравець бачить лише це:</p>
<figure class="post-figure"><picture><source type="image/webp" srcset="https://smallwater.eu/blog/word-search-generator/final.webp" /><img src="https://smallwater.eu/blog/word-search-generator/final.png" alt="Готовий пошук слів 10×10 без позначок" width="976" height="986" loading="lazy" decoding="async" /></picture><figcaption>Готова сітка такою, як її бачить гравець.</figcaption></figure>
<aside class="app-box app-box-callout"><img class="app-box-img" src="https://smallwater.eu/images/crosswords/1-crosswords.jpg" alt="" width="698" height="1516" loading="lazy" decoding="async" /><div class="app-box-body"><p class="app-box-eyebrow">Спробуйте</p><h2 class="app-box-title">Кросворди та головоломки</h2><p>Кросворди, пошук слів, судоку та японські кросворди в одному офлайн-застосунку. Як створюються головоломки і що нового.</p><div class="app-box-links"><a class="btn btn-primary" href="https://smallwater.eu/crosswords-ios" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-ios" data-ga-app="crosswords" data-ga-store="app_store">App Store</a><a class="btn btn-ghost" href="https://smallwater.eu/crosswords-android" data-ga="blog_app_link_clicked,outbound_store_click" data-ga-category="crosswords" data-ga-destination="/crosswords-android" data-ga-app="crosswords" data-ga-store="google_play">Google Play</a></div></div></aside>
<h2 id="коли-сітка-не-вдалася"><a class="heading-anchor" href="#коли-сітка-не-вдалася" aria-hidden="true" tabindex="-1">#</a>Коли сітка не вдалася</h2>
<p>Якщо генератор за 2 000 спроб не досягає рівно N порожніх клітинок, викиньте всю сітку і почніть заново, щонайбільше 100 разів. Жодного латання дірок. Це грубо, але для сіток до 12×12 цього вистачає із запасом. Більші сітки потребують більше спроб, як показує таблиця нижче.</p>
<h2 id="успішність-і-швидкість-залежно-від-розміру-сітки"><a class="heading-anchor" href="#успішність-і-швидкість-залежно-від-розміру-сітки" aria-hidden="true" tabindex="-1">#</a>Успішність і швидкість залежно від розміру сітки</h2>
<p>Генератор працював на 300 сітках для кожного розміру, зі справжніми жартами і словацьким словником (Node.js 22 на серверному процесорі, телефон буде повільнішим; зображення зроблено з англійським словником, цифри зі словацьким, бо він менший, а отже це суворіший тест). «З першої спроби» означає, що перше коло заповнення завершилося повною сіткою.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Сітка</th>
<th align="right">З першої спроби</th>
<th align="right">Середня кількість спроб</th>
<th align="right">Медіана часу</th>
<th align="right">Слів у сітці</th>
<th align="right">Частка діагоналей</th>
</tr>
</thead>
<tbody><tr>
<td>8×8</td>
<td align="right">78 %</td>
<td align="right">1,30</td>
<td align="right">1,2 мс</td>
<td align="right">12,7</td>
<td align="right">24 %</td>
</tr>
<tr>
<td>10×10</td>
<td align="right">70 %</td>
<td align="right">1,42</td>
<td align="right">2,2 мс</td>
<td align="right">20,4</td>
<td align="right">27 %</td>
</tr>
<tr>
<td>12×12</td>
<td align="right">57 %</td>
<td align="right">1,73</td>
<td align="right">4,7 мс</td>
<td align="right">30,1</td>
<td align="right">29 %</td>
</tr>
<tr>
<td>15×15</td>
<td align="right">21 %</td>
<td align="right">5,46</td>
<td align="right">20 мс</td>
<td align="right">48,7</td>
<td align="right">33 %</td>
</tr>
</tbody></table>
</div>
<p>Що це означає для власного генератора:</p>
<ul>
<li><strong>Середнє слово має 4,7 літери</strong>, хоча ціль близько 6. Довгі слова просто частіше не вміщаються в щільну сітку.</li>
<li><strong>Приблизно 9-10 % клітинок спільні для двох слів.</strong> Налаштовувати це не потрібно, це наслідок правила «щонайбільше одна спільна літера».</li>
<li><strong>Діагоналей буде менше, ніж ви очікуєте.</strong> Напрямок вибирається рівномірно з восьми, тож діагоналі мали б становити половину слів. Але в малій сітці діагональне слово значно частіше виходить за край. На 8×8 діагоналі становлять 24 % слів, на 15×15 уже 33 %. Якщо хочете більше діагоналей, дайте їм більшу вагу під час вибору напрямку.</li>
</ul>
<h2 id="порада-та-сама-сітка-при-кожному-запуску"><a class="heading-anchor" href="#порада-та-сама-сітка-при-кожному-запуску" aria-hidden="true" tabindex="-1">#</a>Порада: та сама сітка при кожному запуску</h2>
<p>Для скриншотів у магазинах вам потрібна щоразу та сама сітка. У такому режимі замініть звичайний генератор випадкових чисел простим лінійним конгруентним генератором із фіксованим seed (наприклад, похідним від мови). Якщо вся арифметика залишається в діапазоні, який числа в JavaScript представляють точно, результат однаковий на будь-якому пристрої. Тим самим трюком згенеровано й зображення в цій статті.</p>
<p>Якщо ви розв&#39;язуєте схожу задачу, генеруєте головоломки або вам потрібен мобільний застосунок, <a href="https://smallwater.eu/ua/#kontakt">напишіть нам</a>. Короткі відео з розробки ми публікуємо також на <a href="https://www.youtube.com/@smallwaterinc" target="_blank" rel="noopener">YouTube</a>. Так само покроково описано, <a href="https://smallwater.eu/ua/blog/sudoku-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/word-search-generator/" data-ga-destination="/ua/blog/sudoku-generator/" data-ga-placement="inline">як згенерувати судоку з єдиним розв&#39;язком</a>. Якщо вам потрібен кросворд замість пошуку слів, подивіться, <a href="https://smallwater.eu/ua/blog/swedish-crossword-generator/" data-ga="blog_related_link_clicked" data-ga-source-path="/ua/blog/word-search-generator/" data-ga-destination="/ua/blog/swedish-crossword-generator/" data-ga-placement="inline">як згенерувати сканворд із зашифрованим словом</a>.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
