Match Azahar's stylus to DeSmuME

Replace Azahar's inverted cursor in Vulkan and software rendering with the
same supersampled white pen, dark outline and lower-left nib as DeSmuME.
Keep clipping anchored to the touch point and preserve the input behavior.

Verify: tools/verify.sh format
PS5_FRONTENDS='es-de picker' PS5_VULKAN_TEMPLATE="$PWD/build/picker-template" tools/verify.sh unit build integration evidence
182 unit and 182 integration tests pass; native core ABI/provenance pass.
The parity regression fails on the old cursor and passes at 1x, 5x and 18x.

Evidence: evidence/azahar-stylus-parity/{capture,expectation}.json
Fresh base-PS5 launch draws the stylus in both captured frames and exits
cleanly; stored eboot and DS/3DS core hashes match the staged package.
105 evidence records replay successfully. Core-switch crash is unresolved.
This commit is contained in:
Mihawk committed 2026-10-07 20:25:33 -04:00
1 parent 63e11a3db0
commit ab4666cc5f
4 files changed
+324 -54

No files matched your search

@@ -0,0 +1,72 @@
{
"step": "azahar-stylus-parity",
"recorded": "2026-10-08T00:25:07.482933+00:00",
"revision": "working tree on 63e11a3",
"command": "tools/verify.sh format; PS5_FRONTENDS=\"es-de picker\" PS5_VULKAN_TEMPLATE=\"$PWD/build/picker-template\" tools/verify.sh unit build integration evidence; python3 tools/deploy-title.py",
"lines": [
"format: PASS",
"182 unit and 182 integration tests: PASS",
"Azahar software raster matches DeSmuME at 1x, 5x and 18x: PASS",
"Vulkan shader compile/link, pen predicate parity and clipped nib coordinates: PASS",
"Old Azahar raster fails the DeSmuME pixel comparison as expected",
"Native Azahar build, ABI and cursor-patch provenance: PASS",
"Stored eboot.bin and both cores match staged SHA-256: PASS",
"Core-switch crash remains unresolved; no lifecycle changes made",
"Fresh PS5 Azahar launch: cursor captured, 399 frames by 7 seconds, clean exit"
],
"native_azahar": {
"sha256": "54cdcf809cf521eb544a60c26726a3074045c812c53942ce3cb9b4b383b2b0f6",
"bytes": 62186560,
"abi_passed": true,
"source_revision": "4aef900a1a903d0aa6ea86fdb1e18236fb0d5907",
"port_inputs_sha256": {
"tools/core-fork.sh": "392c8a7577eaa7a81e99f58a6e006cecab92610d959dc460aabc3ef1064ac1b0",
"tooling/native/ps5-core.ld": "5ffe0f0e99e23de5245afb46fa6dfda6dd860b53d942a5fe2f06eaf9e520df42",
"tooling/native/core_cxx_runtime.cpp": "6d7c11d8595f47601b3a281e616033ea5d28c6329872aa286d4d81c597509433",
"tools/build-azahar.sh": "2418b991f7f4a71c4c4c984ab7831dad085861c6a40e82c0e8ca791acee2f40c",
"tooling/azahar/ps5-toolchain.cmake": "930762cf6f56d1e8b1a927bd70ac5e782f77cdb9b9622617973ef92ce881c398",
"tooling/azahar/touchpad-stylus.patch": "fa8187c2919fda1152b611318aaeced29f9c4e8ccb25997f25f230ce624ccd6f"
}
},
"deployed_readback": {
"eboot.bin": {
"bytes": 38772204,
"sha256": "096ba6d77e9a6667f0d41c1473268200316cd086ee6b4d83523ada646e255287",
"matches_staged": true
},
"cores/desmume_libretro.so": {
"bytes": 5626312,
"sha256": "b1b4d20bf62a4115007646b76ae7f5e04ddb661b8f9cb5b79a00c940c3b801a4",
"matches_staged": true
},
"cores/azahar_libretro.so": {
"bytes": 62186560,
"sha256": "54cdcf809cf521eb544a60c26726a3074045c812c53942ce3cb9b4b383b2b0f6",
"matches_staged": true
}
},
"preview_sha256": "d7d3b09c0ad154398920b1f614a9d5c3cb4dec9dd71156197ea995226ca15a84",
"raw_directory": "klog/azahar-stylus-parity",
"console_run": {
"command": "python3 klog/azahar-stylus-parity/run-fresh.py",
"build_id": "cbe6d6f13b15aaaf5f75604e555562a32f5dce39ef0a434e84a661dee6a2ae83",
"raw_capture": "klog/run-PPSA99169-202311.log",
"frontend_log": "klog/retroarch-202311.log",
"events": [
"input: pad script MARK at 7.011 s: frames=399 worst_ms=80.4 over_40ms=7",
"input: pad script STOP at 9.01 s",
"rarch_main returned = 0"
],
"frames": [
{
"path": "klog/pad-202311/pad-shot-1.png",
"sha256": "54eaff3ddb6428207c2760e30c39f02b53c1244242c08080ae1eb43f3a6a8efb"
},
{
"path": "klog/pad-202311/pad-shot-2.png",
"sha256": "cc1920a1ad0e2a7337099e11b4ed18144faf232c292ee6443bdc6d29ef327361"
}
],
"visual_check": "Both screenshots show the white pen with dark outline/nib pointing down-left on the lower screen."
}
}
@@ -0,0 +1,17 @@
{
"step": "azahar-stylus-parity",
"note": "Matching DeSmuME pen in Azahar; crash investigation is separate.",
"must_contain": [
"format: PASS",
"182 unit and 182 integration tests: PASS",
"Azahar software raster matches DeSmuME at 1x, 5x and 18x: PASS",
"Vulkan shader compile/link, pen predicate parity and clipped nib coordinates: PASS",
"Old Azahar raster fails the DeSmuME pixel comparison as expected",
"Native Azahar build, ABI and cursor-patch provenance: PASS",
"Stored eboot.bin and both cores match staged SHA-256: PASS",
"Core-switch crash remains unresolved; no lifecycle changes made",
"Fresh PS5 Azahar launch: cursor captured, 399 frames by 7 seconds, clean exit"
],
"must_not_contain": [],
"human_check": "Check the stylus with physical touchpad movement, press and drag on the TV."
}
+74 -20
View File
@@ -127,32 +127,83 @@ int main() {
}
''')
def test_azahar_software_stylus(self):
source = self.patched('azahar', 'src/citra_libretro/input/mouse_tracker.cpp')
def test_azahar_stylus_matches_desmume(self):
software = self.patched('azahar', 'src/citra_libretro/input/mouse_tracker.cpp')
desmume = self.patched('desmume', 'desmume/src/frontend/libretro/libretro.cpp')
shader = self.patched('azahar', 'src/video_core/host_shaders/vulkan_cursor.frag')
# Compile the real shaders together, then exercise the fragment's actual
# shape predicate against DeSmuME's rasterizer at native and scaled sizes.
with tempfile.TemporaryDirectory() as td:
for ext in ('vert', 'frag'):
Path(td, f'cursor.{ext}').write_text(self.patched(
'azahar', f'src/video_core/host_shaders/vulkan_cursor.{ext}'))
subprocess.run(['glslangValidator', '-V', '-l', 'cursor.vert', 'cursor.frag'],
cwd=td, check=True, capture_output=True)
shape = shader[shader.index(' float along'):shader.index(' color +=')]
shape = shape.replace('p.x', 'dx').replace('p.y', 'dy')
shape = shape.replace('vec3 ink', 'uint32_t ink').replace('vec3(1.0)', '0xffffff')
shape = shape.replace('vec3(32.0, 40.0, 48.0) / 255.0', '0x202830')
# GLSL literals are floats; preserve that in the extracted C++ predicate.
import re
shape = re.sub(r'(\d+\.\d+)', r'\1f', shape)
self.compile_run("""
#include <algorithm>
#include <cmath>
#include <cassert>
#include <cstdint>
#include <vector>
using std::min; using std::max; using std::abs;
constexpr int RETRO_PIXEL_FORMAT_XRGB8888=1;
int colorMode=1;
uint32_t pointer_color_32=0xffffff;
namespace Layout {
struct FramebufferLayout {
bool IsWithinTouchscreen(int x,int y)const{return x>=10&&x<30&&y>=20&&y<40;}
int left,top,right,bottom;
bool IsWithinTouchscreen(int x,int y)const{return x>=left&&x<right&&y>=top&&y<bottom;}
}; }
struct SoftwareCursorRenderer {
void Render(int,int,float,float,float,const Layout::FramebufferLayout&,void*);
};
""" + function(source, 'void SoftwareCursorRenderer::Render(') + """
uint32_t shader_sample(float dx,float dy,uint32_t original) {
""" + shape + """
return ink;
}
return original;
}
""" + function(desmume, 'static void DrawStylus(') +
function(software, 'void SoftwareCursorRenderer::Render(') + """
int main() {
SoftwareCursorRenderer cursor; Layout::FramebufferLayout layout;
for(float x : {10.f,29.f}) for(float y : {20.f,39.f}) {
std::vector<uint32_t> pixels(40*50,0xff123456);
cursor.Render(40,50,x,y,8,layout,pixels.data());
assert(pixels[static_cast<int>(y)*40+static_cast<int>(x)]==0xffedcba9);
for(int py=0;py<50;++py) for(int px=0;px<40;++px)
if(!layout.IsWithinTouchscreen(px,py)) assert(pixels[py*40+px]==0xff123456);
SoftwareCursorRenderer cursor;
for(int scale : {1,5,18}) {
const int w=40*scale,h=32*scale;
Layout::FramebufferLayout layout{0,0,w,h};
for(int x : {0,w/2,w-1}) for(int y : {0,h/2,h-1}) {
std::vector<uint32_t> ds(w*h,0x123456),az=ds;
DrawStylus(reinterpret_cast<uint16_t*>(ds.data()),w,w,h,x,y,scale);
cursor.Render(w,h,x,y,8*scale,layout,az.data());
assert(ds==az);
for(int py=0;py<h;++py) for(int px=0;px<w;++px) {
unsigned r=0,g=0,b=0;
for(int sy=0;sy<2;++sy) for(int sx=0;sx<2;++sx) {
auto c=shader_sample((px-x+(sx-.5f)*.5f)/scale,
(py-y+(sy-.5f)*.5f)/scale,0x123456);
r+=(c>>16)&255;g+=(c>>8)&255;b+=c&255;
}
assert(ds[py*w+px]==((r/4)<<16|(g/4)<<8|(b/4)));
}
// Preserve alpha and never paint outside the lower screen.
layout={3*scale,4*scale,w-3*scale,h-4*scale};
az.assign(w*h,0xff123456);
cursor.Render(w,h,x,y,8*scale,layout,az.data());
for(int py=0;py<h;++py) for(int px=0;px<w;++px) {
assert((az[py*w+px]>>24)==255);
if(!layout.IsWithinTouchscreen(px,py)) assert(az[py*w+px]==0xff123456);
else assert((az[py*w+px]&0xffffff)==ds[py*w+px]);
}
layout={0,0,w,h};
}
}
cursor.Render(40,50,10,20,8,layout,nullptr);
cursor.Render(40,50,10,20,8,{0,0,40,50},nullptr);
}
""")
@@ -167,17 +218,20 @@ int main() {
#include <initializer_list>
struct Rect {float left,top,right,bottom; float GetHeight()const{return bottom-top;}};
int main() {
for(float scale : {1.f,18.f}) for(float x : {0.f,160.f,319.f})
for(float resolution : {1.f,5.f,18.f}) for(float x : {0.f,160.f,319.f})
for(float y : {0.f,120.f,239.f}) {
struct {float width,height; Rect bottom_screen;} layout{
400*scale,480*scale,{40*scale,240*scale,360*scale,480*scale}};
struct {float projected_x,projected_y;} cursor{x*scale,y*scale};
400*resolution,480*resolution,{40*resolution,240*resolution,360*resolution,480*resolution}};
struct {float projected_x,projected_y;} cursor{x*resolution,y*resolution};
""" + source[start:end] + """
assert(sizeof(vertices)/sizeof(float)==18);
assert(vertices[0]==cx && vertices[1]==cy); // nib is the exact touch point
for(unsigned i=0;i<18;i+=2) {
assert(vertices[i]>=bl && vertices[i]<=br);
assert(vertices[i+1]>=bt && vertices[i+1]<=bb);
assert(sizeof(vertices)/sizeof(float)==24);
for(unsigned i=0;i<24;i+=4) {
float px=(vertices[i]+1)*buf_w/2, py=(vertices[i+1]+1)*buf_h/2;
assert(px>=layout.bottom_screen.left-.001 && px<=layout.bottom_screen.right+.001);
assert(py>=layout.bottom_screen.top-.001 && py<=layout.bottom_screen.bottom+.001);
// Every clipped vertex still measures its local position from the nib.
assert(std::abs(px-(abs_x+vertices[i+2]*scale))<.001);
assert(std::abs(py-(abs_y+vertices[i+3]*scale))<.001);
}
}
}
+161 -34
View File
@@ -61,7 +61,7 @@
last_moved = std::chrono::steady_clock::now();
}
@@ -340,49 +353,24 @@
@@ -340,47 +353,40 @@
return; // No framebuffer data available
}
@@ -94,24 +94,39 @@
- int pixelIndex = y * bufferWidth + x;
- // XOR blend for visibility on any background
- pixels[pixelIndex] ^= cursorColor;
+ // A pen whose nib is the touch point, clipped to the bottom screen.
+ static const char glyph[12][13] = {
+ "## ", "#.# ", " #.# ", " #.## ",
+ " #..# ", " #...# ", " #...# ", " #...# ",
+ " #...# ", " #...#", " #.# ", " # "
+ };
+ const int size = std::max(1, static_cast<int>(renderRatio / 8));
+ // Same supersampled white pen and dark nib/outline as DeSmuME.
+ const float size = std::max(1.0f, renderRatio / 8.0f);
+ const int x = static_cast<int>(projectedX), y = static_cast<int>(projectedY);
+ auto* pixels = static_cast<uint32_t*>(framebuffer_data);
+ for (int row = 0; row < 12 * size; ++row) {
+ for (int col = 0; col < 12 * size; ++col) {
+ const int x = static_cast<int>(projectedX) + col;
+ const int y = static_cast<int>(projectedY) + row;
+ if (glyph[row / size][col / size] == '#' && x >= 0 && y >= 0 &&
+ x < bufferWidth && y < bufferHeight && layout.IsWithinTouchscreen(x, y)) {
+ pixels[y * bufferWidth + x] ^= 0x00ffffff;
+ for (int py = std::max(0, static_cast<int>(y - 21 * size));
+ py < std::min(bufferHeight, static_cast<int>(y + 2 * size)); ++py) {
+ for (int px = std::max(0, static_cast<int>(x - 2 * size));
+ px < std::min(bufferWidth, static_cast<int>(x + 17 * size)); ++px) {
+ if (!layout.IsWithinTouchscreen(px, py))
+ continue;
+ const uint32_t original = pixels[py * bufferWidth + px];
+ unsigned red = 0, green = 0, blue = 0, covered = 0;
+ for (int sy = 0; sy < 2; ++sy) {
+ for (int sx = 0; sx < 2; ++sx) {
+ const float dx = (px - x + (sx - 0.5f) * 0.5f) / size;
+ const float dy = (py - y + (sy - 0.5f) * 0.5f) / size;
+ const float along = dx * 0.6f - dy * 0.8f;
+ const float side = dx * 0.8f + dy * 0.6f;
+ const float radius = std::min(1.6f, along * 0.32f);
+ const float cap = std::max(0.0f, along - 22.0f);
+ uint32_t colour = original;
+ if (along >= 0 && side * side + cap * cap <= radius * radius) {
+ ++covered;
+ colour = along > 5.0f && along < 20.5f &&
+ std::abs(side) < radius - 0.45f ? 0xffffff : 0x202830;
+ }
+ red += (colour >> 16) & 255;
+ green += (colour >> 8) & 255;
+ blue += colour & 255;
+ }
}
}
}
- }
- }
-
- // Draw horizontal line of cursor
- for (int y = horizontalTop; y < horizontalBottom; ++y) {
@@ -121,11 +136,12 @@
- // XOR blend for visibility on any background
- pixels[pixelIndex] ^= cursorColor;
- }
- }
- }
+ if (covered)
+ pixels[py * bufferWidth + px] = (original & 0xff000000) |
+ ((red / 4) << 16) | ((green / 4) << 8) | (blue / 4);
}
}
}
} // namespace Input
--- a/src/citra_libretro/input/mouse_tracker.h
+++ b/src/citra_libretro/input/mouse_tracker.h
@@ -61,6 +61,7 @@
@@ -138,10 +154,71 @@
float lastMouseY;
--- a/src/video_core/renderer_vulkan/renderer_vulkan.cpp
+++ b/src/video_core/renderer_vulkan/renderer_vulkan.cpp
@@ -1087,28 +1087,15 @@
const float br = (layout.bottom_screen.right / buf_w) * 2.0f - 1.0f;
const float bb = (layout.bottom_screen.bottom / buf_h) * 2.0f - 1.0f;
@@ -485,26 +485,24 @@
present_pipelines[i] = pipeline;
}
- // Build cursor pipeline (simple position-only, inverted color blending)
+ // Build the DeSmuME-style pen pipeline (position and local pen coordinates).
{
const vk::VertexInputBindingDescription cursor_binding = {
.binding = 0,
- .stride = sizeof(float) * 2,
+ .stride = sizeof(float) * 4,
.inputRate = vk::VertexInputRate::eVertex,
};
- const vk::VertexInputAttributeDescription cursor_attribute = {
- .location = 0,
- .binding = 0,
- .format = vk::Format::eR32G32Sfloat,
- .offset = 0,
+ const std::array cursor_attributes = {
+ vk::VertexInputAttributeDescription{0, 0, vk::Format::eR32G32Sfloat, 0},
+ vk::VertexInputAttributeDescription{1, 0, vk::Format::eR32G32Sfloat, sizeof(float) * 2},
};
const vk::PipelineVertexInputStateCreateInfo cursor_vertex_input = {
.vertexBindingDescriptionCount = 1,
.pVertexBindingDescriptions = &cursor_binding,
- .vertexAttributeDescriptionCount = 1,
- .pVertexAttributeDescriptions = &cursor_attribute,
+ .vertexAttributeDescriptionCount = 2,
+ .pVertexAttributeDescriptions = cursor_attributes.data(),
};
const vk::PipelineInputAssemblyStateCreateInfo cursor_input_assembly = {
@@ -528,11 +526,11 @@
const vk::PipelineColorBlendAttachmentState cursor_blend_attachment = {
.blendEnable = true,
- .srcColorBlendFactor = vk::BlendFactor::eOneMinusDstColor,
- .dstColorBlendFactor = vk::BlendFactor::eOneMinusSrcColor,
+ .srcColorBlendFactor = vk::BlendFactor::eOne,
+ .dstColorBlendFactor = vk::BlendFactor::eOneMinusSrcAlpha,
.colorBlendOp = vk::BlendOp::eAdd,
.srcAlphaBlendFactor = vk::BlendFactor::eOne,
- .dstAlphaBlendFactor = vk::BlendFactor::eZero,
+ .dstAlphaBlendFactor = vk::BlendFactor::eOneMinusSrcAlpha,
.alphaBlendOp = vk::BlendOp::eAdd,
.colorWriteMask = vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG |
vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA,
@@ -1075,40 +1073,20 @@
// Convert from bottom-screen-local to layout-absolute, then to NDC
const float abs_x = layout.bottom_screen.left + cursor.projected_x;
const float abs_y = layout.bottom_screen.top + cursor.projected_y;
- const float cx = (abs_x / buf_w) * 2.0f - 1.0f;
- const float cy = (abs_y / buf_h) * 2.0f - 1.0f;
- const float ratio = static_cast<float>(layout.bottom_screen.GetHeight()) / 30.0f;
- const float rw = ratio / buf_w;
- const float rh = ratio / buf_h;
-
- // Bottom screen bounds in NDC
- const float bl = (layout.bottom_screen.left / buf_w) * 2.0f - 1.0f;
- const float bt = (layout.bottom_screen.top / buf_h) * 2.0f - 1.0f;
- const float br = (layout.bottom_screen.right / buf_w) * 2.0f - 1.0f;
- const float bb = (layout.bottom_screen.bottom / buf_h) * 2.0f - 1.0f;
-
- // Crosshair geometry clamped to bottom screen bounds
- const float vl = std::fmax(cx - rw / 5.0f, bl);
- const float vr = std::fmin(cx + rw / 5.0f, br);
@@ -155,10 +232,16 @@
-
- // 12 vertices = 4 triangles (2 for vertical bar, 2 for horizontal bar)
- // clang-format off
+ // Stylus silhouette: the nib stays exactly at the touch coordinate.
+ // Use the existing inversion pipeline so it remains visible over the game.
+ const auto px = [&](float v) { return std::clamp(cx + v * rw / 4.0f, bl, br); };
+ const auto py = [&](float v) { return std::clamp(cy + v * rh / 4.0f, bt, bb); };
+ const float scale = std::max(1.0f, layout.bottom_screen.GetHeight() / 240.0f);
+ // Clip positions and local coordinates together, so clipping never distorts the pen.
+ const float left = std::max(abs_x - 2 * scale, float(layout.bottom_screen.left));
+ const float top = std::max(abs_y - 21 * scale, float(layout.bottom_screen.top));
+ const float right = std::min(abs_x + 17 * scale, float(layout.bottom_screen.right));
+ const float bottom = std::min(abs_y + 2 * scale, float(layout.bottom_screen.bottom));
+ const float l = left / buf_w * 2 - 1, r = right / buf_w * 2 - 1;
+ const float t = top / buf_h * 2 - 1, b = bottom / buf_h * 2 - 1;
+ const float u0 = (left - abs_x) / scale, u1 = (right - abs_x) / scale;
+ const float v0 = (top - abs_y) / scale, v1 = (bottom - abs_y) / scale;
const float vertices[] = {
- // Vertical bar
- vl, vt, vr, vt, vr, vb,
@@ -168,19 +251,63 @@
- hl, ht, hr, hb, hl, hb,
- };
- // clang-format on
+ px(0), py(0), px(1), py(4), px(4), py(1), // nib
+ px(2), py(5), px(5), py(2), px(12), py(9), // shaft
+ px(2), py(5), px(12), py(9), px(9), py(12),
+ l, t, u0, v0, r, t, u1, v0, r, b, u1, v1,
+ l, t, u0, v0, r, b, u1, v1, l, b, u0, v1,
+ };
const u64 size = sizeof(vertices);
auto [data, offset, invalidate] = vertex_buffer.Map(size, 16);
@@ -1119,7 +1106,7 @@
@@ -1118,8 +1096,8 @@
scheduler.Record([this, offset = offset, pipeline = cursor_pipeline](vk::CommandBuffer cmdbuf) {
cmdbuf.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline);
cmdbuf.bindVertexBuffers(0, vertex_buffer.Handle(), {0});
const u32 first_vertex = static_cast<u32>(offset) / (sizeof(float) * 2);
- const u32 first_vertex = static_cast<u32>(offset) / (sizeof(float) * 2);
- cmdbuf.draw(12, 1, first_vertex, 0);
+ cmdbuf.draw(9, 1, first_vertex, 0);
+ const u32 first_vertex = static_cast<u32>(offset) / (sizeof(float) * 4);
+ cmdbuf.draw(6, 1, first_vertex, 0);
});
}
--- a/src/video_core/host_shaders/vulkan_cursor.vert
+++ b/src/video_core/host_shaders/vulkan_cursor.vert
@@ -7,6 +7,10 @@
layout (location = 0) in vec2 vert_position;
+layout (location = 1) in vec2 vert_stylus;
+layout (location = 0) out vec2 stylus;
+
void main() {
+ stylus = vert_stylus;
gl_Position = vec4(vert_position, 0.0, 1.0);
}
--- a/src/video_core/host_shaders/vulkan_cursor.frag
+++ b/src/video_core/host_shaders/vulkan_cursor.frag
@@ -5,8 +5,26 @@
#version 450 core
#extension GL_ARB_separate_shader_objects : enable
+layout (location = 0) in vec2 stylus;
layout (location = 0) out vec4 color;
void main() {
- color = vec4(1.0, 1.0, 1.0, 1.0);
+ // Match DeSmuME's four subpixel samples, including its integer pixel origin.
+ vec2 pixel = fwidth(stylus);
+ vec2 origin = stylus - 0.5 * pixel;
+ color = vec4(0.0);
+ for (int sy = 0; sy < 2; ++sy) {
+ for (int sx = 0; sx < 2; ++sx) {
+ vec2 p = origin + (vec2(sx, sy) - 0.5) * 0.5 * pixel;
+ float along = p.x * 0.6 - p.y * 0.8;
+ float side = p.x * 0.8 + p.y * 0.6;
+ float radius = min(1.6, along * 0.32);
+ float cap = max(0.0, along - 22.0);
+ if (along >= 0.0 && side * side + cap * cap <= radius * radius) {
+ vec3 ink = along > 5.0 && along < 20.5 && abs(side) < radius - 0.45
+ ? vec3(1.0) : vec3(32.0, 40.0, 48.0) / 255.0;
+ color += vec4(ink, 1.0) * 0.25;
+ }
+ }
+ }
}