Field Notes
Tools & Field Notes

Kindle and InkPage

Seven levels of simulating an e-ink display.

6 min read

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."

LevelApproachWhat it can simulateDifficulty
1Fonts and page stylingSong-style typefaces, paper color, ink color, margins, line spacingLow
2Single-frame grayscale shaderGamma, quantization, dithering, grain, ink bleedLow—medium
3Old/new pages + persistent state textureGC16/DU/A2, differential partial refresh, ghosting accumulationMedium
4More complete pixel state modelInk concentration, residual direction, refresh age, relaxation decayMedium—high
5Flutter + native OpenGL ESVRAM reuse, ping-pong framebuffer, precise dirty-region refreshHigh
6Native layout + Vulkan ComputePer-pixel physical state, waveform LUT, GPU compute simulationVery high
7Real e-paper hardware interfaceVendor waveforms, real ink particles and reflective screensLimited 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:

text
Page
 → linear luminance
 → contrast/gamma
 → ink bleed
 → grayscale quantization
 → blue noise
 → paper-white / ink-black mapping

The 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.

text
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/TextPainter handles book layout.
  • RepaintBoundary captures 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 + FragmentShader composites the refresh phase.
  • PictureRecorder writes 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:

text
Old widget overlaid transparently on top of the new widget

but rather:

text
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:

text
R: current ink concentration
G: last drive direction
B: accumulated ghosting amount
A: refresh age since the last full refresh

On refresh, rather than simply blending old and new colors, the state gets updated:

text
New ink state =
  relaxation of the old state
  + current waveform drive
  + residual from the black/white transition direction
  + local refresh age
  + panel spatial non-uniformity

This 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:

text
Flutter
 → page bitmap
 → Kotlin OpenGL ES renderer
 → SurfaceTexture
 → Flutter texture widget

Advantages:

  • 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:

text
EPUB text
 → HarfBuzz/Minikin glyph layout
 → Skia glyph textures
 → Vulkan page texture
 → compute shader updating ink state
 → Vulkan compositing output

A 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:

DimensionCurrent level
Fonts and Chinese typographyHigh
Paper white, ink black, and grayscale textureHigh
Blue noise and ink bleedHigh
Kindle-style refresh phasesMedium-high
Differential partial refreshMedium-high
Ghosting accumulationMedium
Ink particle / charge physical stateNot yet implemented
Native GPU and VRAM controlNot yet implemented
Real reflective screen opticsNot achievable on a phone

The recommended next step is not an immediate full rewrite, but rather:

  1. First upgrade the persistent state from RGB color to multi-channel ink state.
  2. Calibrate GC16/DU/A2 parameters using real-device screen recordings and pixel samples.
  3. Only after confirming that Flutter's screenshots, VRAM, or stability become the bottleneck, migrate to Kotlin + OpenGL ES.
  4. 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."