Kindle and InkPage
Seven levels of simulating an e-ink display.
From easiest to hardest, this can roughly be divided into 7 levels. InkPage is currently at level 3: no longer a simple filter, but a "Flutter multi-texture framebuffer simulator."
| Level | Approach | What it can simulate | Difficulty |
|---|---|---|---|
| 1 | Fonts and page styling | Song-style typefaces, paper color, ink color, margins, line spacing | Low |
| 2 | Single-frame grayscale shader | Gamma, quantization, dithering, grain, ink bleed | Low—medium |
| 3 | Old/new pages + persistent state texture | GC16/DU/A2, differential partial refresh, ghosting accumulation | Medium |
| 4 | More complete pixel state model | Ink concentration, residual direction, refresh age, relaxation decay | Medium—high |
| 5 | Flutter + native OpenGL ES | VRAM reuse, ping-pong framebuffer, precise dirty-region refresh | High |
| 6 | Native layout + Vulkan Compute | Per-pixel physical state, waveform LUT, GPU compute simulation | Very high |
| 7 | Real e-paper hardware interface | Vendor waveforms, real ink particles and reflective screens | Limited by hardware |
1. Fonts and paper style
The simplest approach:
- Switch to Literata, Noto Serif SC.
- Change the background from pure white to a warm, light gray.
- Don't use pure black for the ink color.
- Adjust font weight, line spacing, indentation, and page margins.
- Typeset Chinese and English separately.
At this level, a static screenshot will look like a Kindle, but page turns are still ordinary phone animations.
InkPage has fully implemented this level.
2. Single-frame e-paper shader
Run a fragment shader once over the entire page:
Page
→ linear luminance
→ contrast/gamma
→ ink bleed
→ grayscale quantization
→ blue noise
→ paper-white / ink-black mappingThe advantage is good performance, relatively simple code, and a big improvement for static pages.
The drawback is that the shader doesn't know what the previous page was, so it can't produce genuine ghosting or local refreshes.
InkPage has also implemented this level.
3. Multi-texture refresh simulation
This is InkPage's current dynamic approach.
Old target page texture ─┐
New target page texture ─┼→ Transition shader → Currently displayed
Persistent ink state ─────┤
Blue noise texture ──────┘The current concrete implementation is:
- Flutter
RichText/TextPainterhandles book layout. RepaintBoundarycaptures the new page.- Dynamic textures are capped at 1080 physical pixels on the short edge.
- Three full-screen textures are kept: the old page, the new page, and the persistent ink state.
CustomPainter + FragmentShadercomposites the refresh phase.PictureRecorderwrites the final refresh result back into the persistent state.- DU/A2 only modify the regions whose luminance changed.
- Black-to-white ghosting is more visible than white-to-black.
- GC16 ultimately clears ghosting completely.
- A full refresh runs automatically every 5 pages or when the ghosting score gets too high.
- GLES corrects the texture orientation separately.
- Non-Impeller environments fall back to a static paper-color shader.
So it's no longer the old approach of:
Old widget overlaid transparently on top of the new widgetbut rather:
Keep and update a "current screen ink state image"At this level, the visuals already produce a fairly convincing Kindle page-turn feel.
4. Multi-channel ink state
This is the most natural next step for the current architecture — no need to rewrite everything in Kotlin right away.
For now the persistent state mainly stores the final RGB color. It could instead store more information per pixel:
R: current ink concentration
G: last drive direction
B: accumulated ghosting amount
A: refresh age since the last full refreshOn refresh, rather than simply blending old and new colors, the state gets updated:
New ink state =
relaxation of the old state
+ current waveform drive
+ residual from the black/white transition direction
+ local refresh age
+ panel spatial non-uniformityThis would improve:
- Ghosting gradually worsening after many consecutive page turns.
- The fatigue from repeatedly flipping the same spot black and white.
- Ghosting slightly relaxing after the page sits still for a while.
- Slightly different refresh responses in different areas of the page.
- The GC16 clearing process looking more like a real drive waveform.
This is what I think is the highest-value upgrade for InkPage next.
5. Flutter + Kotlin/OpenGL ES
This level moves the e-paper rendering core into a native SurfaceTexture:
Flutter
→ page bitmap
→ Kotlin OpenGL ES renderer
→ SurfaceTexture
→ Flutter texture widgetAdvantages:
- Textures and framebuffers can be reused indefinitely.
- No more frequent creation, conversion, and disposal of
ui.Image. - Ping-pong framebuffers can be used to update the persistent state.
- Finer control over texture formats, filtering, and VRAM.
- Local refresh can genuinely be driven by dirty rectangles.
- GPU timing and memory monitoring become easier.
But if pages are still captured by Flutter, the layout side doesn't change — only the refresh physics become lower-level.
This is the most practical native path to take when continuing to deepen the current approach.
6. Native layout + Vulkan Compute
The deepest level of software simulation would move both page layout and e-paper state into a native renderer:
EPUB text
→ HarfBuzz/Minikin glyph layout
→ Skia glyph textures
→ Vulkan page texture
→ compute shader updating ink state
→ Vulkan compositing outputA compute shader can maintain, per pixel:
- The black/white ink particle ratio.
- The drive target.
- Residual charge.
- Black-to-white / white-to-black response speed.
- Refresh age.
- Spatial noise.
- Temperature or panel mode parameters.
This is close to an "e-paper display simulation engine," but the amount of work would be enormous. Chinese typography, font shaping, pagination, and image rendering would all have to be reimplemented or wired into Skia/HarfBuzz.
7. Real hardware waveforms
To go any higher, you have to rely on actual E-Ink devices and vendor interfaces:
- BOOX Refresh API.
- Vendor-defined GC16, DU, and A2 waveforms.
- Screen temperature compensation.
- Actual panel residue.
- Real reflected light and frontlight.
What a normal OLED phone cannot simulate includes:
- The reflective paper-like surface.
- How it looks under ambient light.
- Changes in viewing angle.
- Keeping the image on-screen after the display is turned off.
- The physical structure of real ink particles.
Where InkPage currently stands
A fair assessment:
| Dimension | Current level |
|---|---|
| Fonts and Chinese typography | High |
| Paper white, ink black, and grayscale texture | High |
| Blue noise and ink bleed | High |
| Kindle-style refresh phases | Medium-high |
| Differential partial refresh | Medium-high |
| Ghosting accumulation | Medium |
| Ink particle / charge physical state | Not yet implemented |
| Native GPU and VRAM control | Not yet implemented |
| Real reflective screen optics | Not achievable on a phone |
The recommended next step is not an immediate full rewrite, but rather:
- First upgrade the persistent state from RGB color to multi-channel ink state.
- Calibrate GC16/DU/A2 parameters using real-device screen recordings and pixel samples.
- Only after confirming that Flutter's screenshots, VRAM, or stability become the bottleneck, migrate to Kotlin + OpenGL ES.
- Only consider C++/Vulkan Compute once a true per-pixel physical model is genuinely required.
This way, gains in realism can be verified level by level, avoiding a massive rewrite of already-stable EPUB and layout features in the name of going "lower-level."