Если render_2d рисует поверх экрана, то 3D-рендер рисует прямо в мире: боксы вокруг игроков, линии к целям, кольца под ногами, метки на координатах. Всё это один объект render3d и одно событие.
Событие render_3d приходит каждый кадр, когда мир уже нарисован. Разбирать его не нужно: просто передавай событие первым аргументом в любой метод render3d, а положение камеры клиент подставит сам.
mod = Module("ESP", "Visuals")
@events.render_3d
def draw(event):
if not mod.isEnabled():
return
for player in world.players():
render3d.box(event, player, Color(255, 0, 0))Рядом есть второе событие, pre_render_3d: те же методы и те же аргументы, но приходит оно раньше, пока блоки и существа ещё не нарисованы. Поэтому нарисованное там закрывается стенами и спинами, как обычная постройка. Круг под ногами, подсветка пола, разметка на земле выглядят живее именно так, а всё, что игрок должен видеть сквозь стены, оставляй в render_3d.
@events.pre_render_3d
def under_world(event):
me = world.self()
render3d.ring(event, me, 1.2, 0.02, 48, Color(86, 124, 252, 160))Бокс это основа любого ESP. Целью может быть сущность: тогда размер бокса возьмётся из её хитбокса сам, считать ничего не надо.
| Метод | Сигнатура | Что рисует |
|---|---|---|
render3d.box | (event, target, color=None) | Каркас, только рёбра |
render3d.filled_box | (event, target, color=None) | Залитый полупрозрачный бокс |
render3d.glowing_box | (event, target, color=None) | Светящийся бокс |
render3d.box_gradient | (event, target, bottom_color, top_color) | Каркас с переходом цвета снизу вверх |
render3d.filled_box_gradient | (event, target, bottom, top) | Заливка с переходом цвета |
render3d.box_at | (event, x, y, z, width, height, depth, color=None) | Каркас по координатам и размерам |
render3d.filled_box_at | (event, x, y, z, w, h, d, color=None) | Заливка по координатам |
Разберём ESP целиком: цвет, дистанция, заливка, всё настраивается. Тут интересны две вещи. world.players() отдаёт список уже без тебя самого. И цвет меняется по дистанции: два цвета смешиваются через mix, и чем ближе враг, тем краснее бокс.
mod = Module("PlayerESP", "Visuals")
mod.setDesc("боксы вокруг игроков")
filled = Checkbox(mod, "Заливка").set(True)
max_dist = Slider(mod, "Дистанция").min(8).max(128).step(4).set(64).suffix(" м")
NEAR = Color(255, 70, 70)
FAR = Color(86, 124, 252)
@events.render_3d
def draw(event):
if not mod.isEnabled() or not world.ingame():
return
me = world.self()
limit = max_dist.get()
for p in world.players():
dist = float(me.distanceTo(p))
if dist > limit:
continue
# mix(other, k): k=0 свой цвет, k=1 чужой.
# вплотную k около нуля, значит красный; на пределе k около единицы, значит синий
k = min(1.0, dist / limit)
col = NEAR.mix(FAR, k)
render3d.box(event, p, col)
if filled.get():
render3d.filled_box(event, p, col.withAlpha(40))По умолчанию всё нарисованное видно сквозь блоки: так и должен работать ESP, иначе от него нет толку. Но частицы, следы и украшения обычно хочется прятать за стенами, чтобы они не светились сквозь гору. Для этого есть render3d.depth(True): он включает режим, в котором клиент сверяет каждую фигуру с тем, что уже нарисовано в мире.
Режим свой у каждого скрипта и держится до следующего вызова, так что помечать каждое рисование не нужно. Если нужно только на время, есть форма with: на выходе из блока вернётся то, что было.
@events.render_3d
def draw(event):
if not mod.isEnabled():
return
render3d.depth(True) # дальше всё прячется за блоками
render3d.billboards(event, spark, data, True)
with render3d.depth(False): # а рамку цели видно всегда
render3d.box(event, world.target(), Color(255, 80, 80))У каждого метода есть и свой флаг through, и он важнее режима: through=False прячет фигуру за стенами, through=True показывает её всегда, а None (так по умолчанию) отдаёт решение режиму.
render3d.line соединяет две точки, render3d.marker ставит крестик-метку. И линия, и маркер работают только с точками, то есть списками [x, y, z]. Сущность им передавать нельзя, координаты бери сам: [p.getX(), p.getY(), p.getZ()].
| Метод | Сигнатура | Что рисует |
|---|---|---|
render3d.line | (event, start, end=None, color=None, through=None) | Линия: две точки [x, y, z] или сразу шесть чисел одним списком |
render3d.marker | (event, pos, size=0.18, color=None, through=None) | Метка-крестик в точке [x, y, z] |
mod = Module("Tracers", "Visuals")
COL = Color(86, 124, 252)
@events.render_3d
def draw(event):
if not mod.isEnabled() or not world.ingame():
return
me = world.self()
eye = [me.getX(), me.getEyeY(), me.getZ()]
for p in world.players():
head = [p.getX(), p.getY() + 1.8, p.getZ()]
body = [p.getX(), p.getY() + 1.0, p.getZ()]
render3d.line(event, eye, body, COL)
render3d.marker(event, head, 0.2, COL)Маркеры удобны и там, где сущности вообще нет: показать место смерти, точку варпа, склад. Сохрани координаты в storage, и метка переживёт перезаход в игру.
mod = Module("Waypoint", "Other")
db = Storage("waypoint")
def save_here():
p = world.self()
if p is None:
return
db.set("pos", [p.getX(), p.getY(), p.getZ()]).save()
client.msg("Точка сохранена")
Button(mod, "Запомнить точку").action(save_here)
@events.render_3d
def draw(event):
if not mod.isEnabled():
return
pos = db.get("pos", None)
if pos:
render3d.marker(event, pos, 0.3, Color(255, 210, 60))Кольцо (ring) рисуется под ногами цели, так подсвечивают того, кого бьёт Aura. Биллборд (billboard) это картинка в мире, которая всегда развёрнута к камере, как ценник в супермаркете: с какой стороны ни зайди, читается. Годится для иконок над головой и эффектов. А target это готовая подсветка цели: бокс, метка и кольцо сразу, ничего собирать не надо.
| Метод | Сигнатура | Что рисует |
|---|---|---|
render3d.ring | (event, target, radius=0.8, y_offset=0.05, segments=48, color=None, through=None) | Кольцо вокруг цели, segments задаёт гладкость |
render3d.target | (event, target=None, color=None, through=None) | Готовая подсветка цели; без target берётся текущая цель Aura |
render3d.billboard | (event, texture, pos, width=1.0, height=None, color=None, roll=0.0, additive=False, through=None) | Картинка в точке [x, y, z], всегда лицом к камере |
import math, time
mod = Module("TargetRing", "Visuals")
ACCENT = Color(86, 124, 252)
@events.render_3d
def draw(event):
if not mod.isEnabled():
return
t = world.target() # текущая цель Aura, None если её нет
if t is None:
return
# пульсация: радиус дышит по синусоиде
pulse = 0.8 + math.sin(time.time() * 4.0) * 0.08
render3d.ring(event, t, pulse, 0.05, 48, ACCENT)
render3d.glowing_box(event, t, ACCENT.withAlpha(120))Биллборду нужна текстура из assets.image() и точка [x, y, z]. Флаг additive=True включает аддитивное смешивание: картинка станет светиться, как частицы, а не лежать матовой наклейкой.
mod = Module("HeadIcon", "Visuals")
icon = assets.image("assets/skull.png")
@events.render_3d
def draw(event):
if not mod.isEnabled():
return
for p in world.players():
pos = [p.getX(), p.getY() + 2.4, p.getZ()] # чуть выше головы
render3d.billboard(event, icon, pos, 0.5, 0.5, Color(255, 255, 255), 0.0, True)render3d.text ставит надпись прямо в мире: у неё есть точка, размер в блоках и настоящая перспектива, то есть вдалеке она мельче, вблизи крупнее. Это тот же шрифт, что и в интерфейсе клиента, буквы считаются по контурам, поэтому вплотную к камере они не рассыпаются на пиксели.
mod = Module("WorldText", "Visuals")
@events.render_3d
def draw(event):
if not mod.isEnabled() or not world.ingame():
return
for p in world.players():
pos = [p.getX(), p.getY() + 2.4, p.getZ()] # чуть выше головы
render3d.text(event, p.getName().getString(), pos, 0.25, Color(255, 255, 255))size это высота заглавной буквы в блоках: 0.25 примерно четверть блока, 1.0 ростом с блок. По умолчанию надпись развёрнута к камере целиком, как ценник в супермаркете: куда ни отойди, читается.
| Метод | Сигнатура | Что делает |
|---|---|---|
render3d.text | (event, text, pos, size=0.25, color=None, ...) | Одна надпись в точке [x, y, z] или у сущности |
render3d.texts | (event, items, ...) | Пачка надписей одним вызовом |
render3d.text_width | (text, size=0.25, font=None) | Ширина надписи в блоках |
render3d.text_height | (size=0.25, font=None) | Шаг между строками в блоках |
render3d.letters | (text, size=0.25, font=None) | Строка по буквам: (символ, смещение слева, ширина) |
Остальные именованные параметры у text и texts одинаковые.
| Параметр | По умолчанию | Что задаёт |
|---|---|---|
font | None | Имя шрифта: medium, bold, semibold, regular, light, roundbold или свой .ttf из assets.font_family |
outline | None | Цвет обводки вокруг букв |
outline_width | 0.06 | Толщина обводки в долях высоты буквы |
outline_steps | 4 | Сколько направлений у обводки: 4 это крест, 8 добавляет диагонали и углы букв |
shadow | None | Цвет тени под текстом |
shadow_offset | (0.05, 0.07) | Сдвиг тени вправо и вниз, тоже в долях высоты |
facing | True | True к камере целиком, "y" только по горизонту, False по своим yaw и pitch |
yaw / pitch | 0.0 | Куда повёрнута надпись при facing=False, углы как у игрока |
roll | 0.0 | Наклон в своей плоскости, градусы (только у text) |
align | "center" | Точка это центр надписи, "left" или "right" сдвигают её вбок |
through | None | Видно ли сквозь блоки: False прячет надпись за стенами, True показывает всегда, None отдаёт решение режиму render3d.depth |
additive | False | Свечение вместо обычной заливки |
Обводка и тень это самое полезное здесь: белый текст на светлом небе не виден, а с тонкой тёмной обводкой читается на любом фоне. Их прозрачность клиент подгоняет под прозрачность самой надписи, так что затухающий текст гаснет целиком, а не остаётся контуром.
mod = Module("Nametags3D", "Visuals")
@events.render_3d
def draw(event):
if not mod.isEnabled() or not world.ingame():
return
me = world.self()
for p in world.players():
dist = float(me.distanceTo(p))
pos = [p.getX(), p.getY() + 2.3, p.getZ()]
render3d.text(event, p.getName().getString(), pos, 0.22,
Color(255, 255, 255),
outline=Color(0, 0, 0, 200),
through=False) # за стеной ника не будет
render3d.text(event, "%.1f м" % dist, [pos[0], pos[1] - 0.28, pos[2]], 0.14,
Color(180, 190, 210), through=False)С facing=False надпись перестаёт следить за камерой и висит так, как её поставили: yaw разворачивает по сторонам света, а pitch=90 кладёт её на землю лицом вверх. Углы считаются как у игрока, который смотрит на эту надпись, так что yaw можно брать прямо у себя: world.self().getYaw().
mod = Module("FloorLabel", "Visuals")
@events.render_3d
def draw(event):
if not mod.isEnabled() or not world.ingame():
return
me = world.self()
under = [me.getX(), me.getY() + 0.02, me.getZ()] # у самых ног
render3d.text(event, "точка сбора", under, 0.4, Color(255, 210, 60),
facing=False, yaw=float(me.getYaw()), pitch=90,
outline=Color(0, 0, 0, 180))Когда надписей много, собирай их в один список и отдавай через render3d.texts: как и у частиц, вся пачка уходит видеокарте за одну отправку. Каждый элемент это словарь или кортеж (текст, точка, размер, цвет, наклон, yaw, pitch), а общие параметры вроде обводки задаются один раз на весь вызов.
mod = Module("Labels", "Visuals")
@events.render_3d
def draw(event):
if not mod.isEnabled() or not world.ingame():
return
items = []
for p in world.players():
items.append({
"text": p.getName().getString(),
"pos": [p.getX(), p.getY() + 2.3, p.getZ()],
"size": 0.22,
"color": Color(255, 255, 255),
})
items.append(("%.0f hp" % p.getHealth(),
[p.getX(), p.getY() + 2.05, p.getZ()], 0.16, Color(120, 240, 150)))
render3d.texts(event, items, outline=Color(0, 0, 0, 200))Отдельные буквы нужны, когда каждая живёт своей жизнью: разлетается, падает, крутится. render3d.letters отдаёт готовую раскладку строки, то есть символ, его смещение от левого края и ширину, всё в блоках. Дальше буквы становятся обычными элементами пачки.
import math, time
mod = Module("Scatter", "Visuals")
TEXT = "ROCKSTAR"
SIZE = 0.35
@events.render_3d
def draw(event):
if not mod.isEnabled() or not world.ingame():
return
me = world.self()
width = render3d.text_width(TEXT, SIZE)
now = time.time()
items = []
for i, (char, offset, char_width) in enumerate(render3d.letters(TEXT, SIZE)):
shift = offset + char_width * 0.5 - width * 0.5 # от центра строки
items.append({
"text": char,
"pos": [me.getX() + shift, me.getY() + 2.5 + math.sin(now * 3.0 + i) * 0.15, me.getZ()],
"size": SIZE,
"color": Color(255, 120, 220),
"roll": math.sin(now * 2.0 + i) * 12.0, # буквы качаются
"pitch": math.sin(now + i) * 20.0, # и клюют носом, каждая по-своему
})
# facing=False, иначе углы у элементов не нужны: к камере развернётся вся пачка
render3d.texts(event, items, facing=False, yaw=float(me.getYaw()),
outline=Color(40, 0, 40, 220))Каждый вызов line, billboard и остальных методов выше отправляет свою фигуру видеокарте отдельно. Пока фигур десятки, это незаметно. Сотня частиц или сетка из линий на каждом кадре уже съедают заметную часть FPS, причём не потому, что рисовать тяжело, а потому что отправок много.
Для таких случаев есть billboards и lines. Ты собираешь все фигуры в один обычный список чисел и отдаёшь его одним вызовом, а клиент рисует их за одну отправку. Пятьсот частиц стоят столько же, сколько одна.
| Метод | Сигнатура | Что рисует |
|---|---|---|
render3d.billboards | (event, texture, particles, additive=True, through=None) | Пачка картинок лицом к камере; на каждую по 8 чисел: x, y, z, размер, r, g, b, a |
render3d.lines | (event, segments, additive=True, through=None) | Пачка отрезков; на каждый по 10 чисел: x1, y1, z1, x2, y2, z2, r, g, b, a |
Список плоский: числа идут подряд, без вложенных списков. Цвет задаётся прямо числами от 0 до 255, объект Color тут не нужен. Если чисел в списке чуть больше, чем нужно, хвост просто игнорируется.
import math, time
mod = Module("Sparks", "Visuals")
spark = assets.resource("textures/glowing.png") # мягкая точка, уже есть в клиенте
@events.render_3d
def draw(event):
if not mod.isEnabled():
return
me = world.self()
if me is None:
return
x, y, z = me.getX(), me.getY() + 1.0, me.getZ()
now = time.time()
data = []
for i in range(200): # 200 искр по кругу
a = now * 1.5 + i * 0.31
r = 1.6 + math.sin(now * 2.0 + i) * 0.4
data.extend((
x + math.cos(a) * r, # x, y, z
y + math.sin(now + i * 0.7) * 0.5,
z + math.sin(a) * r,
0.16, # размер
120.0, 205.0, 255.0, 200.0, # r, g, b, a от 0 до 255
))
render3d.billboards(event, spark, data, True)С lines то же самое, только чисел десять и вместо размера идёт вторая точка отрезка. Так рисуют сетки, трассы и следы, где отрезков сотни.
mod = Module("Traces", "Visuals")
@events.render_3d
def draw(event):
if not mod.isEnabled():
return
me = world.self()
if me is None:
return
x, y, z = me.getX(), me.getY() + 1.0, me.getZ()
segments = []
for p in world.players():
segments.extend((
x, y, z, # откуда
p.getX(), p.getY() + 1.0, p.getZ(), # куда
255.0, 80.0, 60.0, 180.0, # цвет линии
))
render3d.lines(event, segments, True)