---
title: "Окно, холст и Layout"
description: "Окно и холст в Ebitengine: ebiten layout и логический размер, автоскейлинг окна, SetWindowSize, изменяемое окно, полноэкранный режим и DPI на Retina-экранах."
course: "Ebitengine: 2D-игры на Go"
url: "https://golangmaster.ru/courses/ebitengine/lessons/03-okno-i-holst/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Окно, холст и Layout

*Курс «Ebitengine: 2D-игры на Go», урок 3 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/ebitengine/lessons/03-okno-i-holst/*

У ebitengine окно и холст — не одно и то же, и смешивать их начинают почти все новички. В этом уроке разберём ebiten layout: как движок превращает ваши 320 на 240 в любое окно, что происходит при растягивании, полноэкранном режиме и на Retina-экранах, где физические пиксели не совпадают с логическими.

Разберём типичную сцену растерянности. Вы написали игру под холст 320 на 240, запустили — и окно внезапно 640 на 480. Потом потянули за уголок, и игра либо растянулась, либо осталась прежней — в зависимости от того, что вы вернули в Layout. Что вообще рисует Draw: окно или холст? Если под тем же вопросом вы открывали GUI-библиотеки вроде [Fyne](/courses/fyne/), там логика похожая — но у игры, в отличие от формы с кнопками, размер холста часто осознанно фиксированный, и это порождает слой масштабирования, которого в GUI нет. Сегодня мы раскладываем всё по полочкам: три размера, которые живут одновременно, контракт Layout и практические рецепты — от изменяемого окна до полноэкранного режима и DPI.

### ebitengine окно: три размера вместо одного

Первый шаг к порядку в голове — разделить три величины, которые в разговоре все называют словом «размер». Первый — размер окна операционной системы в device-independent пикселях: именно его движок передаёт в Layout как outsideWidth и outsideHeight. Второй — логический размер холста вашей игры: то, что Layout возвращает, и то, в каких координатах живёт ваш Draw. Третий — физические пиксели монитора, которых на HiDPI-экране в два или полтора раза больше, чем логических точек; ими движок занимается сам. Документация формулирует контракт аккуратно: Layout принимает внешний размер в device-independent пикселях и возвращает логический размер экрана; масштаб от холста к итоговому окну движок подбирает автоматически.

Зачем нужен этот средний слой — логический холст? Затем же, зачем художник рисует кадр фиксированного формата: геймплей не должен зависеть от того, развернул ли игрок окно на весь 4K-монитор или запустил в уголке ноутбука. Вы проектируете игру в своих координатах, а проблему «как это вписать в произвольное окно» решает движок. Плата за удобство — необходимость понимать, что screen в Draw — это не окно ОС: когда вы захотите узнать «размер экрана», сначала решите, какой из трёх размеров вам на самом деле нужен.

### Три размера одной игры

| Размер | Где живёт | Кто им управляет |
| --- | --- | --- |
| Окно ОС (outside) | device-independent пиксели окна или монитора | пользователь и SetWindowSize; движок передаёт в Layout |
| Логический холст | координаты Draw и всей игровой логики | ваш Layout — фикс или расчёт от outside |
| Физические пиксели | реальная матрица монитора | движок через device scale factor |

### ebiten layout: контракт логического холста

Layout — самый короткий метод интерфейса ebiten.Game, и в нём больше решений, чем кажется. Два полюса. Полюс первый — фиксированный холст: return 320, 240 в любом случае, что угодно творится с окном. Это выбор пиксель-арт игр: пропорции стабильны, масштаб предсказуем, координаты никогда не меняются. Полюс второй — резиновый холст: return outsideWidth, outsideHeight, и тогда ваша игра получает в Draw столько же места, сколько занимает окно. Так делают стратегии и головоломки с интерфейсом, который должен растягиваться вместе с окном.

Между полюсами есть и компромиссы: возвращать outside, поджатый под минимальную ширину, или фиксированную высоту с плавающей шириной. Два технических факта завершают контракт. Layout вызывается почти каждый кадр — это не одноразовая настройка, поэтому тяжёлые вычисления внутри него дурной тон. А возврат неположительных чисел недопустим: документация предупреждает, что вызывающий код может запаниковать, — холста размером ноль не бывает. Из тех же документов следует и гарантия первого кадра: Layout вызывается до первого Update, поэтому даже расчётный холст уже знает свои размеры, когда логика делает первый шаг. Обратите внимание и на сигнатуру: из трёх методов интерфейса только Layout не возвращает ошибку — ему нечего сообщать о сбое, он описывает мир, а не выполняет рискованную работу. Для тонких случаев с дробными размерами существует и float-версия интерфейса LayoutF: она подключается отдельным интерфейсом LayoutFer, о котором знать достаточно, пока вы не упёрлись в полупиксельные границы.

### Два полюса Layout в одном файле

```go
type Game struct{}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
        // Пиксель-арт: холст всегда один и тот же
        return 320, 240
}

func (g *Editor) Layout(outsideWidth, outsideHeight int) (int, int) {
        // Инструмент уровня: холст следует за окном
        return outsideWidth, outsideHeight
}
```

Обе реализации легальны: движку важен сам контракт, а не конкретная стратегия. Фиксированный возврат оставляет геймплей стабильным при любом окне, возврат outside делает холст равным окну в device-independent пикселях. В проекте вы выбираете одну стратегию для одной игры, а не держите две структуры — здесь они показаны рядом для контраста.

**Вопрос:** Что означает аргумент outsideWidth в Layout?

- Ширину монитора в физических пикселях
- ✅ Ширину окна (или монитора в фулскрине) в device-independent пикселях
- Ширину последнего нарисованного спрайта
- Ширину холста, которую вы вернули в прошлом кадре

Outside — это внешний размер в device-independent пикселях: на десктопе окно, в полноэкранном режиме монитор, в браузере — элемент страницы. Физические пиксели монитора в Layout не попадают: ими управляет движок через масштаб.

### Масштаб: как 320 на 240 занимает весь экран

Что происходит между холстом и окном? Движок считает масштаб: во сколько раз логические пиксели вырастают до размеров окна. Окно 640 на 480 при холсте 320 на 240 даёт масштаб ровно два — и это идеальный случай для пиксель-арта: каждый логический пиксель становится аккуратным квадратом два на два. Стремитесь к таким целым множителям: делайте окно кратным холсту через SetWindowSize или холст кратным ожидаемому окну. Когда пропорции не совпадают — скажем, окно 640 на 400 при холсте 320 на 240, — движок вписывает холст в окно, сохраняя пропорции картинки, и лишнее место окна остаётся незанятым.

Нецелый масштаб — тихий враг пиксель-арта. При множителе 1,5 один логический пиксель то занимает два физических, то один, и на экране появляются пиксели разной толщины — «грязная» картинка, в которой художник узнаёт свой арт с трудом. Движок при этом ничего не предупредит: он честно отрисует дробные пиксели, и заметить размытость можно только глазами на реальном мониторе — скриншоты, пересжатые браузером, артефакт легко скрывают. Отсюда практическое правило ретро-игр: выбирайте холст так, чтобы он делил типовые окна без остатка, 320 на 240 и 640 на 360 — проверенные классики. А если окно меняет размер, движок пересчитывает масштаб на лету: Layout вызывается почти каждый кадр именно поэтому.

### Какой масштаб получится?

```go
func main() {
        ebiten.SetWindowSize(960, 720)
        ebiten.SetWindowTitle("Scale test")
        if err := ebiten.RunGame(&Game{}); err != nil {
                log.Fatal(err)
        }
}

func (g *Game) Layout(w, h int) (int, int) {
        return 320, 240
}
```

**Что выведет программа?**

- Масштаб 2: движок никогда не масштабирует больше, чем вдвое
- ✅ Масштаб 3: и ширина, и высота делятся нацело
- Масштаб 1,5 по ширине и 3 по высоте — картинка растянется
- Холст останется 320 на 240 физически, а по краям будет пустота

960 против 320 по ширине даёт множитель 3, 720 против 240 по высоте — тоже 3. Пропорции окна и холста совпадают, поэтому картинка заполняет окно целиком с идеальным целым масштабом. Меняйте числа в уме: 960 на 600 при том же холсте дало бы нецелый множитель и неровные пиксели.

### Изменяемое окно и полноэкранный режим

По умолчанию окно Ebitengine имеет фиксированный размер: пользователь не может его растянуть мышью. Разрешение даёт ebiten.SetWindowResizingMode: режим WindowResizingModeEnabled разрешает менять размер за рамку, режим по умолчанию WindowResizingModeDisabled запрещает, а WindowResizingModeOnlyFullscreenEnabled разрешает только переключение в полноэкранный режим — последний работает, как честно говорит документация, пока только на macOS. Включённый ресайз не отменяет ваш Layout: вы по-прежнему решаете, холст ли следует за окном, или окно вписывает фиксированный холст. Отключить рамку окна целиком умеет отдельная функция SetWindowDecorated, а лимиты min и max для ресайза ставит SetWindowSizeLimits.

Полноэкранный режим — пара IsFullscreen и SetFullscreen: вторая принимает true или false и переключает режим в любой момент, хоть из Update по нажатию F11. Здесь спрятана ловушка, о которой документация предупреждает прямо: WindowSize продолжает возвращать исходный размер окна даже в фулскрине — размеры монитора дают Monitor().Size(), а целевое разрешение для честного фулскрина считает ScreenSizeInFullscreen. Если игра должна заполнять весь экран без полей, спросите ScreenSizeInFullscreen и подстройте Layout под неё — это ровно тот случай, когда Layout стоит сделать расчётным, а не фиксированным.

### Окно, которое слушается пользователя

```go
func main() {
        ebiten.SetWindowSize(960, 720)
        ebiten.SetWindowTitle("Resizable game")
        // разрешаем тянуть окно за рамку
        ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)

        if err := ebiten.RunGame(&Game{}); err != nil {
                log.Fatal(err)
        }
}

func (g *Game) Update() error {
        if ebiten.IsKeyPressed(ebiten.KeyF) {
                ebiten.SetFullscreen(!ebiten.IsFullscreen())
        }
        return nil
}
```

Пакетные Set-функции вызывают до RunGame — это настройка окружения; но SetFullscreen и IsFullscreen безопасно дёргать и в живом цикле, здесь — по клавише F. Каркас Layout из первого куска не изменился: холст 320 на 240 впишется и в растянутое окно, и в монитор фулскрина. Заметьте, что переключение фулскрина не требует перезапуска игры.

**Вопрос:** Игра в полноэкранном режиме. Что вернёт ebiten.WindowSize()?

- Размер монитора целиком
- ✅ Исходный размер окна, а не монитора
- Ноль: в фулскрине окна нет
- Логический размер холста из Layout

Документация WindowSize подчёркивает: даже в фулскрине функция возвращает исходный размер окна. Размеры монитора даёт Monitor().Size(), а разрешение идеального фулскрина считает ScreenSizeInFullscreen — не путайте эти три функции.

### DPI и Retina: device scale factor

Последний слой — плотность пикселей. На Retina-экране одна логическая точка занимает два физических пикселя, и это отношение называется device scale factor. Движок применяет его автоматически: картинка рендерится в полную физическую плотность, и на 2x-экране выглядит резко. Спросить текущий множитель у кода можно через Monitor().DeviceScaleFactor(); историческая пакетная функция ebiten.DeviceScaleFactor считается устаревшей с версии 2.6 — в старых туториалах вы встретите именно её, так что знайте обе.

Зачем этот множитель вручную? Для режима повышенной чёткости пиксель-арт-игр. По умолчанию резкий пиксель-арт на Retina может выглядеть замыленным: движок масштабирует холст с учётом дробных множителей окна. Официальный рецепт из FAQ: пусть Layout вернёт outside, умноженный на device scale factor, — тогда холст совпадёт с физическими пикселями, и картинка станет пиксель-в-пиксель; готовый пример так и называется highdpi. На мониторах без HiDPI множитель просто равен единице — рецепт от этого не ломается, просто ничего не умножает. Плата за высшую чёткость — больше работы на кадр, поэтому в браузерных сборках есть и обратная опция DisableHiDPI, отключающая HiDPI-рендер ради производительности. Решайте по своей игре: арты с крупным пикселем обычно выигрышнее выглядят с честным масштабом и целыми множителями.

> **Живое место: Layout, который просто возвращает окно.** Совет «верните outsideWidth и outsideHeight» для пиксель-арт игры — ловушка: на Retina-ноутбуке холст вырастает в разы, спрайты усыхают до крошки, а координаты уровней, рассчитанные под 320 на 240, разъезжаются. Фиксированный Layout нужен не потому, что лень считать, а потому, что дизайн игры жёстко привязан к формату кадра. Резиновый холст оправдан, когда интерфейс векторный и адаптивный. Проверка простая: поставьте в Layout печать outsideWidth в лог и посмотрите, как часто она срабатывает при ресайзе — после этого вопрос «почему у меня тормозит Layout» решается сам.

**Вопрос:** Что произойдёт, если Layout вернёт 0 и 0?

- Движок примет ноль как «окно не определено» и пропустит кадр
- Игра соберётся, но Draw не будет вызываться
- ✅ Документация допускает панику вызывающего кода: неположительный холст недопустим
- Движок подставит размер окна вместо нуля

Документация Layout предупреждает: если возвращены неположительные числа, вызывающий код может запаниковать. Холст нулевой площади — бессмыслица для рендера, поэтому вариантов у движка нет. В расчётном Layout добавляйте нижнюю границу размеров.

### Правила холста одним списком

- Пиксель-арт — фиксированный Layout, кратный типовым окнам: 320 на 240, 640 на 360
- Интерфейсные и текстовые игры — возврат outsideWidth и outsideHeight
- Полноэкранные режимы — расчёт от ScreenSizeInFullscreen, а не от WindowSize
- Резкость на Retina — hi-DPI рецепт с device scale factor из примера highdpi
- Никогда не возвращайте ноль и отрицательные числа — вызывающий код может запаниковать
- Тяжёлых вычислений в Layout быть не должно: он вызывается почти каждый кадр

> **Окно не открывается в песочнице.** Разделы этого урока проверяются только локально: окно создаёт операционная система, а браузерной песочнице такие полномочия недоступны. Зато математика масштаба — деление окна на холст, целые и дробные множители — считается на чистом Go: тематическая программа в песочнице сайта прокручивает те же числа, что считает движок при вписывании холста в окно.

### Соберите настройку окна

Восстановите три пропуска: режим ресайза и резиновый Layout.

```go
func main() {
        ebiten.SetWindowSize(960, 720)
        ebiten.SetWindowTitle("Resizable game")
        ebiten.SetWindowResizingMode(ebiten.WindowResizingMode___)
        if err := ebiten.RunGame(&Game{}); err != nil {
                log.Fatal(err)
        }
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
        return ___, ___
}
```

Подсказка: Разрешаем менять размер окна, а холст следует за внешним размером.

Правильные ответы в пропусках: `Enabled`, `outsideWidth`, `outsideHeight`

### Запуск с изменяемым окном

Расставьте строки программы с ресайзом в правильном порядке.

Правильный порядок строк:

1. `package main`
2. `import "github.com/hajimehoshi/ebiten/v2"`
3. `type Game struct{}`
4. `func (g *Game) Update() error { return nil }`
5. `func (g *Game) Draw(screen *ebiten.Image) {}`
6. `func (g *Game) Layout(w, h int) (int, int) { return 320, 240 }`
7. `func main() {`
8. `ebiten.SetWindowSize(960, 720)`
9. `ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)`
10. `ebiten.RunGame(&Game{})`
11. `}`

Резюме слоя отображения такое: окно ОС живёт в device-independent пикселях, Layout превращает его в логический холст, движок масштабирует холст на монитор с учётом DPI, а вы выбираете между фиксированным и резиновым холстом сознательно, а не по привычке. Приятное следствие контракта: значения из Layout можно менять на ходу — захотите переключить пиксель-арт режим на интерфейсный, возвращайте другие числа, и движок подстроится без перезапуска. Проверьте рецепт на практике: локальный проект из урока про [установку и цикл](/courses/ebitengine/lessons/02-ustanovka-i-cikl/) запустите трижды — с фиксированным холстом, с резиновым и с переключателем фулскрина по клавише. На следующем шаге пустой холст перестанет быть пустым: четвёртый урок загружает PNG с прозрачностью и рисует первый спрайт через DrawImage (04-sprajty), а там недалеко и до позиций с пивотами в уроке про прямоугольники (08-pryamougolniki). Если хотите параллельно сравнить, как тот же вопрос «кто кого масштабирует» решается в GUI-мире, у Fyne свой ответ через типовые размеры — [курс Fyne](/courses/fyne/) хорош как противовес.

### Термины урока

- **Логический холст** — Размер, который возвращает Layout: в его координатах живёт Draw и вся игровая логика, независимо от окна.
- **Outside size** — Внешний размер в device-independent пикселях: окно на десктопе, монитор в фулскрине, элемент страницы в браузере.
- **Device scale factor** — Отношение физических пикселей к логическим точкам (2 на Retina). Спрос: Monitor().DeviceScaleFactor(); пакетная DeviceScaleFactor устарела с 2.6.
- **SetWindowResizingMode** — Управляет ресайзом окна: Disabled по умолчанию, Enabled разрешает тянуть рамку, OnlyFullscreenEnabled работает пока только на macOS.
- **WindowSize и Monitor** — WindowSize возвращает исходный размер окна даже в фулскрине; размеры монитора дают Monitor().Size(), целевое разрешение — ScreenSizeInFullscreen.
- **Целый масштаб** — Множитель окна к холсту без дробной части. Нецелый множитель делает пиксели разной толщины — главный враг пиксель-арта.

## Коротко: что запомнить

- Размеров три: окно ОС в device-independent пикселях, логический холст из Layout, физические пиксели монитора.
- Layout принимает outside и возвращает логический размер; вызывается почти каждый кадр; неположительный возврат недопустим.
- Движок сам считает масштаб холста к окну и вписывает картинку с сохранением пропорций; целые множители — признак чистого пиксель-арта.
- Ресайз включает SetWindowResizingMode(Enabled); фулскрин переключают SetFullscreen и IsFullscreen в любой момент игры.
- WindowSize в фулскрине возвращает исходное окно; монитор даёт Monitor().Size(), разрешение фулскрина — ScreenSizeInFullscreen.
- HiDPI рендерится автоматически; рецепт пиксель-в-пиксель — Layout, умноженный на device scale factor, как в примере highdpi из FAQ.

## Домашнее задание

Возьмите каркас из второго урока и соберите лабораторию масштаба: Layout с фиксированным 320 на 240, окно 960 на 720, ресайз включён. Потяните окно до нецелого множителя и посмотрите на края пикселей; затем добавьте переключатель фулскрина по клавише F через SetFullscreen. После этого реализуйте hi-DPI рецепт из FAQ: умножайте outside на Monitor().DeviceScaleFactor() в Layout и сравните резкость. Если под рукой есть Mac, проверьте и WindowResizingModeOnlyFullscreenEnabled: на других системах движок честно трактует его как Disabled. Выпишите в заметки, для какой из ваших будущих игр какой Layout подходит и почему.

## Вопросы и ответы по уроку

### Какой холст выбрать для первой пиксель-арт игры?

Классика — 320 на 240 или 640 на 360: они дают целые множители на типовых окнах и мониторах. Начните с фиксированного Layout и окна, кратного холсту, а резиновый холст оставьте для интерфейсных игр без пиксельного стиля. Проверить себя просто: холст 640 на 360 при окне 960 на 720 даёт нецелый множитель 1,5 и неровные пиксели, а при 1280 на 720 — целые двойки.

### Почему на Retina игра выглядит мыльной?

Движок рендерит в физическую плотность, но дробные множители окна размывают пиксели при масштабировании. Сработает hi-DPI рецепт: верните из Layout outside, умноженный на device scale factor, и добавьте целые множители для окна. Если арты нарисованы под крупный пиксель, лёгкое мыло иногда даже нравится — правило целых множителей не догма, а инструмент.

### Можно ли менять размер окна из кода во время игры?

Да: SetWindowSize работает в любой момент и остаётся актуальным при выходе из фулскрина. Но делайте это осмысленно — игрок вправе держать окно своего размера, а резкое переключение размеров из логики обычно раздражает.
