diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..172727a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,119 @@ +name: build + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # ---- RP2040 / RP2350 ---------------------------------------------- + # FS 0MB: assets live in the CircuitPython partition at 1 MB, so the + # core must not also claim a filesystem at the end of flash. + - name: feather-rp2040-dvi + fqbn: rp2040:rp2040:adafruit_feather_dvi:flash=8388608_0,usbstack=tinyusb + core: rp2040 + defines: "-DEYE_PANEL=EYE_PANEL_DVI -DNUM_EYES=1" + + - name: metro-rp2350-gc9a01a-2eye + fqbn: rp2040:rp2040:adafruit_metro_rp2350:flash=16777216_0,usbstack=tinyusb + core: rp2040 + defines: "-DEYE_PANEL=EYE_PANEL_GC9A01A -DNUM_EYES=2" + + - name: metro-esp32s3-st7789-2eye + fqbn: esp32:esp32:adafruit_metro_esp32s3:PartitionScheme=tinyuf2,USBMode=default + core: esp32 + defines: "-DEYE_PANEL=EYE_PANEL_ST7789 -DNUM_EYES=2" + + name: ${{ matrix.name }} + steps: + - uses: actions/checkout@v4 + + - uses: arduino/setup-arduino-cli@v2 + + - name: Add board manager URLs + run: | + arduino-cli config init + arduino-cli config add board_manager.additional_urls \ + https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json + arduino-cli config add board_manager.additional_urls \ + https://espressif.github.io/arduino-esp32/package_esp32_index.json + arduino-cli core update-index + + - name: Install core + run: arduino-cli core install ${{ matrix.core }}:${{ matrix.core }} + + - name: Install libraries + run: | + arduino-cli lib install \ + "Adafruit GFX Library" \ + "Adafruit BusIO" \ + "Adafruit ST7735 and ST7789 Library" \ + "Adafruit GC9A01A" \ + "Adafruit ILI9341" \ + "ArduinoJson" \ + "SdFat - Adafruit Fork" \ + "Adafruit SPIFlash" + if [ "${{ matrix.core }}" = "esp32" ]; then + arduino-cli lib install "Adafruit TinyUSB Library" + fi + # PicoDVI is the Adafruit fork and is not in the library index. + if [ "${{ matrix.core }}" = "rp2040" ]; then + git clone --depth 1 https://github.com/adafruit/PicoDVI.git \ + "$HOME/Arduino/libraries/PicoDVI" + fi + + - name: Locate the sketch + id: sketch + run: | + INO=$(find . -name '*.ino' -not -path './build/*' | head -n 1) + if [ -z "$INO" ]; then + echo "No .ino found in the checkout." >&2 + exit 1 + fi + DIR=$(dirname "$INO") + echo "dir=$DIR" >> "$GITHUB_OUTPUT" + echo "Sketch: $INO (directory $DIR)" + + - name: Compile + run: | + arduino-cli compile \ + --fqbn "${{ matrix.fqbn }}" \ + --build-property "compiler.cpp.extra_flags=${{ matrix.defines }}" \ + --output-dir "build/${{ matrix.name }}" \ + --warnings default \ + "${{ steps.sketch.outputs.dir }}" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: | + build/${{ matrix.name }}/*.uf2 + build/${{ matrix.name }}/*.bin + if-no-files-found: error + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: adafruit/ci-arduino + path: ci + - name: pre-install + run: bash ci/actions_install.sh + - name: clang + run: python3 ci/run-clang-format.py -e "ci/*" -e "bin/*" -r . + - name: doxygen + env: + GH_REPO_TOKEN: ${{ secrets.GH_REPO_TOKEN }} + PRETTYNAME: "Adafruit Monster Eyes" + run: bash ci/doxy_gen_and_deploy.sh Adafruit_Monster_Eyes diff --git a/Adafruit_Monster_Eyes/Adafruit_Monster_Eyes.ino b/Adafruit_Monster_Eyes/Adafruit_Monster_Eyes.ino new file mode 100644 index 0000000..777b2dc --- /dev/null +++ b/Adafruit_Monster_Eyes/Adafruit_Monster_Eyes.ino @@ -0,0 +1,631 @@ +/** + * @file Adafruit_Monster_Eyes.ino + * @brief Animated eyes for RP2040/RP2350 and ESP32, ported from Adafruit's + * M4_Eyes. + * + * Original: Phillip Burgess for Adafruit Industries, MIT license. + * + * + * @see settings.h for everything a user configures. + */ + +#include "eye.h" +#include + +static int SIZE, HALF; +static float gazeRadius = 1.0f; +static void gazeRadiusInit(void); + +// EYE STATE --------------------------------------------------------------- + +#define NOBLINK 0 +#define ENBLINK 1 +#define DEBLINK 2 + +typedef struct { + float irisSpin, scleraSpin; // RPM * -1024 (negative = clockwise) + uint16_t irisStartAngle, scleraStartAngle; // 0-1023 CCW + uint16_t irisAngle, scleraAngle; // Current rotation + + uint8_t blinkState; + uint32_t blinkDuration; + uint32_t blinkStartTime; + float blinkFactor; + + float eyeX, eyeY; // Position in map space, saved per eye to avoid tearing + float pupilFactor; + float upperLidFactor, lowerLidFactor; +} eyeState; + +static eyeState eye[NUM_EYES]; + +// Shared animation state +static bool eyeInMotion = false; +static float eyeOldX, eyeOldY, eyeNewX, eyeNewY; +static uint32_t eyeMoveStartTime = 0; +static int32_t eyeMoveDuration = 0; +static uint32_t lastSaccadeStop = 0; +static int32_t saccadeInterval = 0; +static uint32_t timeOfLastBlink = 0; +static uint32_t timeToNextBlink = 0; +static float frameEyeX, frameEyeY; + +// Autonomous iris scaling via fractal subdivision (no light sensor here) +#define IRIS_LEVELS 7 +static float irisPrev[IRIS_LEVELS] = {0}; +static float irisNext[IRIS_LEVELS] = {0}; +static uint16_t irisFrame = 0; +static float irisValue = 0.5f; +static float irisMin, irisRange; + +static uint32_t frames = 0; +static uint32_t lastFrameReport = 0; +static uint32_t accFrameMicros = 0, accBusyMicros = 0; + +// SETUP ------------------------------------------------------------------- + +void setup() { + Serial.begin(115200); + + // If the board hangs during startup, the last line printed + // says which step died. A hard fault kills USB +#if STARTUP_GRACE_MS > 0 + delay(STARTUP_GRACE_MS); +#endif + DBGLN("\n--- RP2 Eyes ---"); + DBG("%s, sys clock %lu Hz\n", PLATFORM_NAME, (unsigned long)platformCpuHz()); + DBG("board: %s, %d eye(s), panel %s\n", EYE_BOARD_NAME, NUM_EYES, + (EYE_PANEL == EYE_PANEL_DVI) ? "DVI" + : (EYE_PANEL == EYE_PANEL_GC9A01A) ? "GC9A01A" + : (EYE_PANEL == EYE_PANEL_ILI9341) ? "ILI9341" + : "ST7789"); + // The number that matters for frame rate is fast internal RAM, not total + // heap -- on chips with PSRAM those are very different figures. + DBG("fast RAM available for eye data: %u\n", platformLargestFreeBlock()); + + // Drive mode is checked FIRST, before DVI touches core1 or the PIOs. + // See eye_storage.cpp for why the two modes cannot run at the same time. +#if ENABLE_BOOTSEL_DRIVE + if (eyeStorageDriveModeRequested()) { + eyeStorageRunDriveMode(); // Never returns; reboots on eject + } +#endif + + DBGLN("[1] settings"); + eyeSettingsDefaults(); + DBGLN("[2] storage"); +#if ENABLE_STORAGE + if (eyeStorageBegin()) { + eyeSettingsLoad(CONFIG_FILENAME); + } +#else + DBGLN(" storage disabled; using built-in defaults"); +#endif + eyeSettingsFinalize(); + + // Display first. On DVI this allocates the framebuffer, the single largest + // allocation in the sketch, which must not have to fight fragmentation. + DBGLN("[3] display"); + if (!displayBegin()) { + // Framebuffer allocation failed. Almost always means the eye tables or + // something else claimed RAM first, or the resolution is too large. + pinMode(LED_BUILTIN, OUTPUT); + for (;;) + digitalWrite(LED_BUILTIN, (millis() / 200) & 1); + } + DBG("Display ready, max eye %d. Free heap: %u\n", displayMaxEyeSize(), + platformFreeHeap()); +#if DISPLAY_SELFTEST + displaySelfTest(); +#endif + displayClear(0); + + // Clamp to what the backend can actually give one eye. With two eyes on a + // single framebuffer this is half the width, so a config asking for more + // gets quietly reduced rather than overlapping its neighbor. + // displaySize 0 in config.eye means "fill the display". + if (settings.displaySize <= 0) + settings.displaySize = displayMaxEyeSize(); + if (settings.displaySize > displayMaxEyeSize()) + settings.displaySize = displayMaxEyeSize(); + eyeSettingsFinalize(); // Re-derive coverage for the final displaySize + + // pupilMin/pupilMax in the file are the inverse of the irisMin/irisRange + // the renderer wants. + irisMin = 1.0f - settings.pupilMax; + irisRange = settings.pupilMax - settings.pupilMin; + + // Build the polar and displacement maps. If a config asks for an eye too + // large for the remaining heap, step the size down rather than failing -- + // the maps grow as mapRadius^2, so this converges quickly. + DBGLN("[4] tables"); + uint32_t t0 = millis(); + + // Shrink the eye until the maps fit AND a texture still has somewhere to + // live. + const bool wantTexture = + (settings.irisFile[0] != 0) || (settings.scleraFile[0] != 0); + for (;;) { + if (!eyeTablesInit()) { + if (settings.displaySize <= 96) { + Serial.println("Cannot allocate eye tables even at minimum size."); + for (;;) + delay(1000); + } + settings.displaySize -= 16; + settings.eyeRadius = 0; // Re-derive proportionally + settings.irisRadius = 0; + eyeSettingsFinalize(); + DBG("Not enough RAM for tables; retrying at displaySize %d\n", + settings.displaySize); + continue; + } + uint32_t left = platformLargestFreeBlock(); + if (wantTexture && (settings.displaySize > 96) && + (left < (uint32_t)(HEAP_RESERVE + MIN_TEXTURE_BUDGET))) { + eyeTablesFree(); + settings.displaySize -= 16; + settings.eyeRadius = 0; + settings.irisRadius = 0; + eyeSettingsFinalize(); + DBG("Only %u free after tables; retrying at displaySize %d " + "to leave room for a texture\n", + (unsigned)left, settings.displaySize); + continue; + } + break; + } + DBG("Tables built in %lu ms (size %d, mapRadius %d). Free heap: %u\n", + millis() - t0, settings.displaySize, mapRadius, platformFreeHeap()); + (void)t0; + + // Tell the backend the eye size BEFORE textures claim the heap + SIZE = settings.displaySize; + HALF = SIZE / 2; + gazeRadiusInit(); + displaySetEyeSize(SIZE); +#if DISPLAY_PATHTEST + displayPathTest(SIZE); +#endif + displayClear(settings.eyelidColor); + + // Whatever is left, minus a reserve, is the texture budget. + uint32_t freeHeap = platformLargestFreeBlock(); + uint32_t texBudget = + (freeHeap > HEAP_RESERVE) ? (freeHeap - HEAP_RESERVE) : 0; + DBGLN("[5] media"); + if (!eyeMediaLoad(settings.displaySize, texBudget)) { + Serial.println("Eyelid table allocation failed."); + for (;;) + delay(1000); + } + + // Nothing else reads the filesystem; let go of it so flash stays quiet. +#if ENABLE_STORAGE + eyeStorageEnd(); +#endif + + DBG("Running. Free heap: %u\n", platformFreeHeap()); + // The self-test and path test both push columns, which accumulates into the + // profiling counter. Clear it. + displayBusyMicros = 0; + lastFrameReport = micros(); + + for (uint8_t e = 0; e < NUM_EYES; e++) { + eye[e].irisSpin = -1024.0f * eyeVariant[e].irisSpin; + eye[e].irisStartAngle = eyeVariant[e].irisStartAngle; + eye[e].irisAngle = eye[e].irisStartAngle; + eye[e].scleraSpin = -1024.0f * eyeVariant[e].scleraSpin; + eye[e].scleraStartAngle = eyeVariant[e].scleraStartAngle; + eye[e].scleraAngle = eye[e].scleraStartAngle; + eye[e].blinkState = NOBLINK; + eye[e].blinkFactor = 0.0f; + eye[e].pupilFactor = 0.5f; + eye[e].upperLidFactor = 1.0f; + eye[e].lowerLidFactor = 1.0f; + eye[e].eyeX = eye[e].eyeY = (float)mapRadius; + } + + eyeOldX = eyeNewX = eyeOldY = eyeNewY = (float)mapRadius; + frameEyeX = frameEyeY = (float)mapRadius; + + randomSeed(micros()); +} + +// ONCE-PER-FRAME ANIMATION ------------------------------------------------ + +// The original derives the gaze radius purely in map units: +// (mapDiameter - displaySize * pi/2) * 0.75 +// So also bound it by how far the iris actually moves in SCREEN pixels, via +// map2screen(). The 0.2433 factor is calibrated so the stock 240/125/0.6 +// demon eye comes out at its original radius, leaving good configs unchanged. +static void gazeRadiusInit(void) { + float r = ((float)mapDiameter - (float)SIZE * (float)M_PI_2) * 0.75f; + + const float travel = 0.2433f * (float)SIZE; // Allowed screen-pixel travel + float s = travel / ((float)M_PI_2 * (float)settings.eyeRadius); + if (s > 0.999f) + s = 0.999f; + const float rScreen = (float)mapRadius * asinf(s); + // 10% tolerance. The two formulas agree exactly only at the ratio they were + // calibrated on (eyeRadius 125 at displaySize 240); at other sizes the + // stock eyeRadius = size/2 + 5 lands a few percent apart + if (r > rScreen * 1.10f) { + DBG("gaze radius %.1f exceeds what a %d px window can show; " + "capping to %.1f\n", + r, SIZE, rScreen); + DBG(" (eyeRadius %d is large for displaySize %d; near %d fits)\n", + settings.eyeRadius, SIZE, SIZE / 2 + 5); + r = rScreen; + } + if (r < 1.0f) + r = 1.0f; + gazeRadius = r; + DBG("Gaze radius %.1f map px (%.1f screen px)\n", gazeRadius, + map2screen((int)gazeRadius)); +} + +static void updateGaze(uint32_t t) { + int32_t dt = t - eyeMoveStartTime; + + if (eyeInMotion) { + if (dt >= eyeMoveDuration) { // Destination reached + eyeInMotion = false; + uint32_t limit = min((uint32_t)1000000, settings.gazeMax); + eyeMoveDuration = random(35000, limit); // Hold before next microsaccade + if (!saccadeInterval) { + lastSaccadeStop = t; + saccadeInterval = random(eyeMoveDuration, settings.gazeMax); + } + eyeMoveStartTime = t; + frameEyeX = eyeOldX = eyeNewX; + frameEyeY = eyeOldY = eyeNewY; + } else { // Interpolate, ease in/out + float e = (float)dt / (float)eyeMoveDuration; + e = 3.0f * e * e - 2.0f * e * e * e; + frameEyeX = eyeOldX + (eyeNewX - eyeOldX) * e; + frameEyeY = eyeOldY + (eyeNewY - eyeOldY) * e; + } + } else { + frameEyeX = eyeOldX; + frameEyeY = eyeOldY; + if (dt > eyeMoveDuration) { + const float rFull = gazeRadius; + + if ((t - lastSaccadeStop) > (uint32_t)saccadeInterval) { + // Full saccade: anywhere in the disc. + eyeNewX = random(-rFull, rFull); + float h2 = rFull * rFull - eyeNewX * eyeNewX; + float h = (h2 > 0.0f) ? sqrtf(h2) : 0.0f; + eyeNewY = random(-h, h); + eyeMoveDuration = random(83000, 166000); + saccadeInterval = 0; + } else { + float rMicro = rFull * (0.07f / 0.75f); + if (rMicro < 1.0f) + rMicro = 1.0f; + float dx = random(-rMicro, rMicro); + float h2 = rMicro * rMicro - dx * dx; + float h = (h2 > 0.0f) ? sqrtf(h2) : 0.0f; + eyeNewX = frameEyeX - mapRadius + dx; + eyeNewY = frameEyeY - mapRadius + random(-h, h); + eyeMoveDuration = random(7000, 25000); + } + + // Keep the gaze inside the disc + float d2 = eyeNewX * eyeNewX + eyeNewY * eyeNewY; + if (d2 > (rFull * rFull)) { + float k = rFull / sqrtf(d2); + eyeNewX *= k; + eyeNewY *= k; + } + + eyeNewX += mapRadius; // Into map space + eyeNewY += mapRadius; + eyeMoveStartTime = t; + eyeInMotion = true; + } + } +} + +static void updateIris(void) { + float n, sum = 0.5f; + for (uint16_t i = 0; i < IRIS_LEVELS; i++) { + uint16_t iexp = 1 << (i + 1); + uint16_t imask = iexp - 1; + uint16_t ibits = irisFrame & imask; + if (ibits) { + float weight = (float)ibits / (float)iexp; + n = irisPrev[i] * (1.0f - weight) + irisNext[i] * weight; + } else { + n = irisNext[i]; + irisPrev[i] = irisNext[i]; + irisNext[i] = -0.5f + ((float)random(1000) / 999.0f); + } + iexp = 1 << (IRIS_LEVELS - i); + sum += n / (float)iexp; + } + irisValue = irisMin + (sum * irisRange); + if ((++irisFrame) >= (1 << IRIS_LEVELS)) + irisFrame = 0; +} + +static void updateBlinks(uint32_t t) { + if ((t - timeOfLastBlink) >= timeToNextBlink) { + timeOfLastBlink = t; + uint32_t d = random(36000, 72000); + for (uint8_t e = 0; e < NUM_EYES; e++) { + if (eye[e].blinkState == NOBLINK) { + eye[e].blinkState = ENBLINK; + eye[e].blinkStartTime = t; + eye[e].blinkDuration = d; + } + } + timeToNextBlink = d * 3 + random(4000000); + } +} + +static void updateEye(uint8_t e, uint32_t t) { + eyeState &E = eye[e]; + +#if NUM_EYES > 1 + E.eyeX = frameEyeX + ((e & 1) ? EYE_FIXATE : -EYE_FIXATE); +#else + E.eyeX = frameEyeX; +#endif + E.eyeY = frameEyeY; + E.pupilFactor = irisValue; + + float uq, lq; + if (settings.tracking) { + int ix = (int)map2screen((float)mapRadius - E.eyeX) + HALF; + int iy = (int)map2screen((float)mapRadius - E.eyeY) + HALF; + iy += (int)(settings.irisRadius * settings.trackFactor); + if (eyeVariant[e].eyelidMirror) + ix = SIZE - 1 - ix; + if (ix < 0) + ix = 0; + else if (ix > SIZE - 1) + ix = SIZE - 1; + if (iy > upperOpen[ix]) + uq = 1.0f; + else if (iy < upperClosed[ix]) + uq = 0.0f; + else + uq = (float)(iy - upperClosed[ix]) / + (float)(upperOpen[ix] - upperClosed[ix]); + lq = 1.0f - uq; + } else { + uq = lq = 1.0f; // Fully open when not blinking + } + E.upperLidFactor = (E.upperLidFactor * 0.6f) + (uq * 0.4f); + E.lowerLidFactor = (E.lowerLidFactor * 0.6f) + (lq * 0.4f); + + if (E.blinkState) { + if ((t - E.blinkStartTime) >= E.blinkDuration) { + if (++E.blinkState > DEBLINK) { + E.blinkState = NOBLINK; + E.blinkFactor = 0.0f; + } else { + E.blinkDuration *= 2; // Opening is half the speed of closing + E.blinkStartTime = t; + E.blinkFactor = 1.0f; + } + } else { + E.blinkFactor = (float)(t - E.blinkStartTime) / (float)E.blinkDuration; + if (E.blinkState == DEBLINK) + E.blinkFactor = 1.0f - E.blinkFactor; + } + } + + // Cast through int32_t, NOT straight to uint16_t. Once irisSpin * mins goes + // negative, a direct float->unsigned conversion is undefined behavior, and + // ARM's __aeabi_f2uiz saturates it to 0 -- which pins the iris angle at zero + // and the iris stops spinning. Going via a signed int wraps correctly. + float mins = (float)millis() / 60000.0f; + E.irisAngle = + (uint16_t)(int32_t)((float)E.irisStartAngle + E.irisSpin * mins + 0.5f); + E.scleraAngle = (uint16_t)(int32_t)((float)E.scleraStartAngle + + E.scleraSpin * mins + 0.5f); +} + +// RENDER ------------------------------------------------------------------ + +static void EYE_HOT_FN(renderEye)(uint8_t e) { + eyeState &E = eye[e]; + const int half = HALF; + const int stride = displayColumnStride; + + const int xPositionOverMap = (int)(E.eyeX - (float)half); + const int yPositionOverMap = (int)(E.eyeY - (float)half); + + const float upperLidFactor = (1.0f - E.blinkFactor) * E.upperLidFactor; + const float lowerLidFactor = (1.0f - E.blinkFactor) * E.lowerLidFactor; + const int irisH = irisHeight(), irisW = irisWidth(); + const int scleraH = scleraHeight(), scleraW = scleraWidth(); + const uint16_t *iris = irisData, *sclera = scleraData; + const int iPupilFactor = + (int)((float)irisH * 256.0f * (1.0f / E.pupilFactor)); + const uint16_t irisAngle = E.irisAngle; + const uint16_t pupilColor = OUT16(settings.pupilColor); + const uint16_t backColor = OUT16(settings.backColor); + const uint16_t eyelidColor = OUT16(settings.eyelidColor); + const uint16_t irisMirror = eyeVariant[e].irisMirror; + const uint16_t scleraMirror = eyeVariant[e].scleraMirror; + const uint16_t scleraAngle = E.scleraAngle; + const bool mirrorLids = eyeVariant[e].eyelidMirror; + + for (int x = 0; x < SIZE; x++) { + const int lidColumn = mirrorLids ? (SIZE - 1 - x) : x; + + // Destination pointer starts at the TOP of the column and walks up-screen + // as y increases. + uint16_t *dst = displayColumn(e, x); + + int y1 = + (int)lowerClosed[lidColumn] + + (int)(0.5f + lowerLidFactor * (float)((int)lowerOpen[lidColumn] - + (int)lowerClosed[lidColumn])); + int y2 = + (int)upperClosed[lidColumn] + + (int)(0.5f + upperLidFactor * (float)((int)upperOpen[lidColumn] - + (int)upperClosed[lidColumn])); + if (y1 > SIZE - 1) + y1 = SIZE - 1; + else if (y1 < 0) + y1 = 0; + if (y2 > SIZE - 1) + y2 = SIZE - 1; + else if (y2 < 0) + y2 = 0; + + if (y1 >= y2) { + // Lid closed far enough that no eye pixels show in this column + for (int y = 0; y < SIZE; y++, dst += stride) + *dst = eyelidColor; + displayColumnDone(e, x); + continue; + } + + // Lower eyelid + int y = 0; + for (; y < y1; y++, dst += stride) + *dst = eyelidColor; + + // Displacement lookup setup for this column. Only one quadrant of the + // table exists; sign and axis swapping cover the rest. + const uint8_t *displaceX, *displaceY; + int8_t xmul; + if (x < half) { + displaceX = &displace[(half - 1) - x]; + displaceY = &displace[((half - 1) - x) * half]; + xmul = -1; + } else { + displaceX = &displace[x - half]; + displaceY = &displace[(x - half) * half]; + xmul = 1; + } + + const int xx = xPositionOverMap + x; + + for (; y <= y2; y++, dst += stride) { + const int yy = yPositionOverMap + y; + int doff, dx, dy; + + if (y < half) { + doff = (half - 1) - y; + dy = -(int)displaceY[doff]; + } else { + doff = y - half; + dy = (int)displaceY[doff]; + } + dx = displaceX[doff * half]; + + if (dx >= 255) { // Outside the eyeball + *dst = eyelidColor; + continue; + } + dx *= xmul; + int mx = xx + dx; + int my = yy + dy; + + if ((mx < 0) || (mx >= mapDiameter) || (my < 0) || (my >= mapDiameter)) { + *dst = backColor; // Off the map + continue; + } + + int angle, dist, moff; + if (my >= mapRadius) { + if (mx >= mapRadius) { // Quadrant 1: direct + mx -= mapRadius; + my -= mapRadius; + moff = my * mapRadius + mx; + angle = polarAngle[moff]; + dist = polarDist[moff]; + } else { // Quadrant 2: rotate 90, mirror X + mx = mapRadius - 1 - mx; + my -= mapRadius; + angle = polarAngle[mx * mapRadius + my] + 768; + dist = polarDist[my * mapRadius + mx]; + } + } else { + if (mx < mapRadius) { // Quadrant 3: rotate 180 + mx = mapRadius - 1 - mx; + my = mapRadius - 1 - my; + moff = my * mapRadius + mx; + angle = polarAngle[moff] + 512; + dist = polarDist[moff]; + } else { // Quadrant 4: rotate 270, mirror Y + mx -= mapRadius; + my = mapRadius - 1 - my; + angle = polarAngle[mx * mapRadius + my] + 256; + dist = polarDist[my * mapRadius + mx]; + } + } + + if (dist >= 0) { // Sclera + int a = ((angle + scleraAngle) & 1023) ^ scleraMirror; + int tx = (a * scleraW) >> 10; + int ty = (dist * scleraH) >> 7; + *dst = sclera[ty * scleraW + tx]; + } else if (dist > -128) { // Iris or pupil + int ty = (int)(((uint32_t)(-dist * iPupilFactor)) >> 15); + if (ty >= irisH) { + *dst = pupilColor; + } else { + int a = ((angle + irisAngle) & 1023) ^ irisMirror; + int tx = (a * irisW) >> 10; + *dst = iris[ty * irisW + tx]; + } + } else { + *dst = backColor; // Back of eye + } + } + + // Upper eyelid + for (; y < SIZE; y++, dst += stride) + *dst = eyelidColor; + + displayColumnDone(e, x); + } +} + +// LOOP -------------------------------------------------------------------- + +void loop() { + uint32_t t = micros(); + + updateGaze(t); + updateBlinks(t); + updateIris(); + + displayFrameBegin(); + for (uint8_t e = 0; e < NUM_EYES; e++) { + updateEye(e, t); + displayEyeBegin(e); + renderEye(e); + displayEyeEnd(e); + } + displayFrameEnd(); + + frames++; +#if PROFILE_FRAME + accFrameMicros += micros() - t; + accBusyMicros += displayBusyMicros; + displayBusyMicros = 0; +#endif + if ((t - lastFrameReport) >= 1000000) { +#if PROFILE_FRAME + float f = (float)accFrameMicros / (float)frames / 1000.0f; + float tx = (float)accBusyMicros / (float)frames / 1000.0f; + Serial.printf("%lu fps frame %.1f ms = render %.1f + transfer %.1f " + "(%.0f%% transfer)\n", + frames, f, f - tx, tx, (f > 0.0f) ? (100.0f * tx / f) : 0.0f); + accFrameMicros = accBusyMicros = 0; +#else + Serial.printf("%lu fps\n", frames); +#endif + frames = 0; + lastFrameReport = t; + } +} diff --git a/Adafruit_Monster_Eyes/display_dvi.cpp b/Adafruit_Monster_Eyes/display_dvi.cpp new file mode 100644 index 0000000..01208cb --- /dev/null +++ b/Adafruit_Monster_Eyes/display_dvi.cpp @@ -0,0 +1,86 @@ +/** + * @file display_dvi.cpp + * @brief Display backend: PicoDVI framebuffer (RP2 only). + * + * One framebuffer holds every eye; with two they sit side by side, each + * centred in half the screen, which caps eye size at width/2. A column of the + * eye runs UP the screen, so the stride is one negative row. + */ + +#include "eye.h" + +#if EYE_DISPLAY == EYE_DISPLAY_DVI + +#include +#include + +static DVIGFX16 dvi(DVI_RESOLUTION, DVI_PIN_CONFIG); + +static int fbW = 0, fbH = 0; +static int originX[NUM_EYES], originY = 0, eyeSize = 0; + +int displayColumnStride = 0; ///< Row-to-row step in the buffer +volatile uint32_t displayBusyMicros = + 0; ///< Time pushing pixels // Always 0: nothing to push + +bool displayBegin(void) { + // This allocates the framebuffer -- 153,600 bytes at 320x240x16. It is the + // single largest allocation in the sketch, so it must happen before the + // polar maps + if (!dvi.begin()) + return false; + fbW = dvi.width(); + fbH = dvi.height(); + displayColumnStride = -fbW; + DBG("DVI up: %dx%d, %d eye(s)\n", fbW, fbH, NUM_EYES); + return true; +} + +int displayMaxEyeSize(void) { + int w = fbW / NUM_EYES; // Each eye gets an equal horizontal slice + return (w < fbH) ? w : fbH; +} + +void displaySetEyeSize(int size) { + eyeSize = size; + originY = (fbH - size) / 2; + if (originY < 0) + originY = 0; + const int slice = fbW / NUM_EYES; + for (int e = 0; e < NUM_EYES; e++) { + originX[e] = e * slice + (slice - size) / 2; + if (originX[e] < 0) + originX[e] = 0; + } +} + +void displayClear(uint16_t color) { dvi.fillScreen(color); } + +void displayFrameBegin(void) {} +void displayEyeBegin(int eye) { (void)eye; } + +uint16_t *displayColumn(int eye, int x) { + // Start at the TOP of the column; the renderer walks up-screen + return &dvi.getBuffer()[(originY + eyeSize - 1) * fbW + originX[eye] + x]; +} + +void displayColumnDone(int eye, int x) { + (void)eye; + (void)x; +} + +void displayEyeEnd(int eye) { (void)eye; } +void displayFrameEnd(void) {} + +void displaySelfTest(void) { + const uint16_t bars[4] = {0xF800, 0x07E0, 0x001F, 0xFFFF}; + for (uint8_t i = 0; i < 4; i++) { + dvi.fillScreen(bars[i]); + delay(600); + } + dvi.fillScreen(0); + dvi.fillRect(0, 0, fbW / 2, fbH / 2, 0xFFE0); + delay(1200); +} + +#endif // EYE_DISPLAY == EYE_DISPLAY_DVI diff --git a/Adafruit_Monster_Eyes/display_esp_lcd.cpp b/Adafruit_Monster_Eyes/display_esp_lcd.cpp new file mode 100644 index 0000000..ac90413 --- /dev/null +++ b/Adafruit_Monster_Eyes/display_esp_lcd.cpp @@ -0,0 +1,342 @@ +/** + * @file display_esp_lcd.cpp + * @brief Display backend: ESP-IDF esp_lcd, SPI panel IO with async DMA. + * + * The Adafruit path pushes pixels with the CPU. esp_lcd hands the buffer to a + * DMA channel and returns, calling back when it lands, so a frame costs + * max(render, transfer) rather than their sum. + * + * ESP-IDF ships only a few panel drivers in core, ST7789 among them. GC9A01A + * is not one, so this backend creates an ST7789 panel object -- the addressing + * commands are identical -- and sends the GC9A01A vendor init itself. + */ + +#include "eye.h" + +#if EYE_DISPLAY == EYE_DISPLAY_ESP_LCD + +#include +#include +#include +#include +#include +#include + +// GC9A01A vendor initialisation, transcribed from Adafruit_GC9A01A.cpp. +// Format: command, length, data... A length with 0x80 set means "then wait". +// +// The GC9A01A shares ST77xx's addressing commands (CASET 0x2A, RASET 0x2B, +// RAMWR 0x2C), so esp_lcd's ST7789 panel object drives it correctly for +// pixels -- only this power-on sequence differs +#if ESP_LCD_DRIVER == ESP_LCD_DRV_GC9A01A +static const uint8_t gc9a01aInit[] = { + 0xEF, 0, 0xEB, 1, 0x14, 0xFE, 0, 0xEF, 0, 0xEB, 1, 0x14, + 0x84, 1, 0x40, 0x85, 1, 0xFF, 0x86, 1, 0xFF, 0x87, 1, 0xFF, + 0x88, 1, 0x0A, 0x89, 1, 0x21, 0x8A, 1, 0x00, 0x8B, 1, 0x80, + 0x8C, 1, 0x01, 0x8D, 1, 0x01, 0x8E, 1, 0xFF, 0x8F, 1, 0xFF, + 0xB6, 2, 0x00, 0x00, 0x36, 1, 0x48, // MADCTL: MX | BGR + 0x3A, 1, 0x05, // COLMOD: 16 bits per pixel + 0x90, 4, 0x08, 0x08, 0x08, 0x08, 0xBD, 1, 0x06, 0xBC, 1, 0x00, + 0xFF, 3, 0x60, 0x01, 0x04, 0xC3, 1, 0x13, // POWER2 + 0xC4, 1, 0x13, // POWER3 + 0xC9, 1, 0x22, // POWER4 + 0xBE, 1, 0x11, 0xE1, 2, 0x10, 0x0E, 0xDF, 3, 0x21, 0x0C, 0x02, + 0xF0, 6, 0x45, 0x09, 0x08, 0x08, 0x26, 0x2A, // Gamma 1 + 0xF1, 6, 0x43, 0x70, 0x72, 0x36, 0x37, 0x6F, // Gamma 2 + 0xF2, 6, 0x45, 0x09, 0x08, 0x08, 0x26, 0x2A, // Gamma 3 + 0xF3, 6, 0x43, 0x70, 0x72, 0x36, 0x37, 0x6F, // Gamma 4 + 0xED, 2, 0x1B, 0x0B, 0xAE, 1, 0x77, 0xCD, 1, 0x63, 0xE8, 1, + 0x34, // Frame rate + 0x62, 12, 0x18, 0x0D, 0x71, 0xED, 0x70, 0x70, 0x18, 0x0F, 0x71, 0xEF, + 0x70, 0x70, 0x63, 12, 0x18, 0x11, 0x71, 0xF1, 0x70, 0x70, 0x18, 0x13, + 0x71, 0xF3, 0x70, 0x70, 0x64, 7, 0x28, 0x29, 0xF1, 0x01, 0xF1, 0x00, + 0x07, 0x66, 10, 0x3C, 0x00, 0xCD, 0x67, 0x45, 0x45, 0x10, 0x00, 0x00, + 0x00, 0x67, 10, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x01, 0x54, 0x10, 0x32, + 0x98, 0x74, 7, 0x10, 0x85, 0x80, 0x00, 0x00, 0x4E, 0x00, 0x98, 2, + 0x3E, 0x07, 0x35, 0, // Tearing effect on + 0x21, 0, // Inversion on + 0x11, 0x80, // Sleep out, then wait + 0x29, 0x80, // Display on, then wait + 0x00 // End of list +}; +#endif + +static esp_lcd_panel_io_handle_t ioHandle[NUM_EYES] = {}; +static esp_lcd_panel_handle_t panelHandle[NUM_EYES] = {}; + +// STRIPE BUFFERING +static uint16_t *scratch = NULL; // 2 stripe buffers, DMA-capable +static uint8_t scratchIdx = 0; +static int stripeW = 1; // Columns per draw_bitmap call +static int stripeBase = 0; // First column of the stripe being filled +static int panelW = 0, panelH = 0; +static int originX = 0, originY = 0, eyeSize = 0; + +static volatile int pending[NUM_EYES] = {}; + +int displayColumnStride = -1; ///< Row-to-row step in the buffer +volatile uint32_t displayBusyMicros = 0; ///< Time pushing pixels + +static const int csPin[NUM_EYES] = {TFT_CS +#if NUM_EYES > 1 + , + TFT1_CS +#endif +}; +static const int dcPin[NUM_EYES] = {TFT_DC +#if NUM_EYES > 1 + , + TFT1_DC +#endif +}; +static const int rstPin[NUM_EYES] = {TFT_RST +#if NUM_EYES > 1 + , + TFT1_RST +#endif +}; + +static bool IRAM_ATTR onColorDone(esp_lcd_panel_io_handle_t io, + esp_lcd_panel_io_event_data_t *ev, + void *ctx) { + (void)io; + (void)ev; + int e = (int)(intptr_t)ctx; + if (pending[e] > 0) + pending[e]--; + return false; +} + +// Wait until at most `limit` transfers are outstanding for this eye. Bounded, +// so a misconfigured panel cannot lock the sketch up. +static void drain(int e, int limit) { + uint32_t guard = 0; + while ((pending[e] > limit) && (++guard < 2000000)) { /* spin */ + } + if (guard >= 2000000) { + static bool warned = false; + if (!warned) { + warned = true; + Serial.println("esp_lcd transfer stalled -- try EYE_DISPLAY_TFT."); + } + pending[e] = 0; + } +} + +#if ESP_LCD_DRIVER == ESP_LCD_DRV_GC9A01A +static void sendVendorInit(esp_lcd_panel_io_handle_t io) { + const uint8_t *p = gc9a01aInit; + uint8_t cmd; + while ((cmd = *p++) != 0x00) { + uint8_t x = *p++; + uint8_t n = x & 0x7F; + esp_lcd_panel_io_tx_param(io, cmd, n ? p : NULL, n); + p += n; + if (x & 0x80) + delay(150); + } +} +#endif + +bool displayBegin(void) { + spi_bus_config_t bus = {}; + bus.sclk_io_num = TFT_SCK; + bus.mosi_io_num = TFT_MOSI; + bus.miso_io_num = -1; + bus.quadwp_io_num = -1; + bus.quadhd_io_num = -1; + // Largest single transfer: one column of 16-bit pixels. + bus.max_transfer_sz = TFT_H * 2 + 64; + + if (spi_bus_initialize((spi_host_device_t)ESP_LCD_HOST, &bus, + SPI_DMA_CH_AUTO) != ESP_OK) { + Serial.println("spi_bus_initialize failed"); + return false; + } + + eyePanelReset(rstPin, NUM_EYES); + + for (int e = 0; e < NUM_EYES; e++) { + esp_lcd_panel_io_spi_config_t io = {}; + io.cs_gpio_num = csPin[e]; + io.dc_gpio_num = dcPin[e]; + io.spi_mode = 0; + io.pclk_hz = TFT_SPI_HZ; + io.trans_queue_depth = 4; + io.lcd_cmd_bits = 8; + io.lcd_param_bits = 8; + io.on_color_trans_done = onColorDone; + io.user_ctx = (void *)(intptr_t)e; + + if (esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)ESP_LCD_HOST, &io, + &ioHandle[e]) != ESP_OK) { + Serial.printf("panel IO %d failed\n", e); + return false; + } + + esp_lcd_panel_dev_config_t pc = {}; + // GC9A01A wants BGR ordering; its stock MADCTL is MX | BGR. + // Reset is driven once + pc.reset_gpio_num = -1; + pc.bits_per_pixel = 16; +#if ESP_LCD_DRIVER == ESP_LCD_DRV_GC9A01A +#define EYE_RGB_ORDER_BGR 1 ///< 1 when the panel expects BGR element order +#else +#define EYE_RGB_ORDER_BGR 0 ///< 1 when the panel expects BGR element order +#endif +#if defined(ESP_IDF_VERSION) && defined(ESP_IDF_VERSION_VAL) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0) + pc.rgb_ele_order = EYE_RGB_ORDER_BGR ? LCD_RGB_ELEMENT_ORDER_BGR + : LCD_RGB_ELEMENT_ORDER_RGB; +#else + pc.rgb_endian = EYE_RGB_ORDER_BGR ? LCD_RGB_ENDIAN_BGR : LCD_RGB_ENDIAN_RGB; +#endif +#else + pc.rgb_endian = EYE_RGB_ORDER_BGR ? LCD_RGB_ENDIAN_BGR : LCD_RGB_ENDIAN_RGB; +#endif + + if (esp_lcd_new_panel_st7789(ioHandle[e], &pc, &panelHandle[e]) != ESP_OK) { + Serial.printf("panel %d failed\n", e); + return false; + } +#if ESP_LCD_DRIVER == ESP_LCD_DRV_GC9A01A + // The vendor sequence does everything panel_init() would (sleep out, + // MADCTL, COLMOD, display on) + sendVendorInit(ioHandle[e]); +#else + esp_lcd_panel_init(panelHandle[e]); +#endif + esp_lcd_panel_invert_color(panelHandle[e], ESP_LCD_INVERT ? true : false); + esp_lcd_panel_swap_xy(panelHandle[e], ESP_LCD_SWAP_XY ? true : false); + esp_lcd_panel_mirror(panelHandle[e], ESP_LCD_MIRROR_X ? true : false, + ESP_LCD_MIRROR_Y ? true : false); + esp_lcd_panel_disp_on_off(panelHandle[e], true); + pending[e] = 0; + DBG(" esp_lcd panel %d ready (CS=GPIO%d DC=GPIO%d)\n", e, csPin[e], + dcPin[e]); + } + + panelW = TFT_W; + panelH = TFT_H; +#if TFT_BACKLIGHT >= 0 + pinMode(TFT_BACKLIGHT, OUTPUT); + digitalWrite(TFT_BACKLIGHT, HIGH); +#endif + DBG("esp_lcd up: %d panel(s), %dx%d at %ld Hz\n", NUM_EYES, panelW, panelH, + (long)TFT_SPI_HZ); + return true; +} + +int displayMaxEyeSize(void) { return (panelW < panelH) ? panelW : panelH; } + +void displaySetEyeSize(int size) { + eyeSize = size; + originX = (panelW - size) / 2; + originY = (panelH - size) / 2; + if (originX < 0) + originX = 0; + if (originY < 0) + originY = 0; + + stripeW = 1; + for (int w = ESP_LCD_STRIPE_COLS; w >= 1; w--) { + if ((size % w) == 0) { + stripeW = w; + break; + } + } + displayColumnStride = -stripeW; + + if (scratch) + heap_caps_free(scratch); + // MALLOC_CAP_DMA: handed straight to a DMA channel. + scratch = (uint16_t *)heap_caps_malloc((size_t)stripeW * size * 2 * + sizeof(uint16_t), + MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + scratchIdx = 0; + stripeBase = 0; + if (!scratch) { + Serial.println("DMA stripe buffer allocation failed!"); + return; + } + DBG(" esp_lcd stripes: %d columns, %d draws per eye, %u bytes\n", stripeW, + size / stripeW, + (unsigned)((size_t)stripeW * size * 2 * sizeof(uint16_t))); +} + +static void fillPanel(int e, uint16_t color) { + if (!scratch) + return; + const int w = stripeW; + uint16_t *buf = scratch; + for (int i = 0; i < w * panelH; i++) + buf[i] = color; + for (int x = 0; x < panelW; x += w) { + int cols = (x + w <= panelW) ? w : (panelW - x); + if (cols != w) + break; // Leave a ragged edge rather than corrupt memory + drain(e, 0); + esp_lcd_panel_draw_bitmap(panelHandle[e], x, 0, x + cols, panelH, buf); + pending[e]++; + } + drain(e, 0); +} + +void displayClear(uint16_t color) { + for (int e = 0; e < NUM_EYES; e++) + fillPanel(e, OUT16(color)); +} + +void displayFrameBegin(void) {} +void displayEyeBegin(int eye) { (void)eye; } + +uint16_t *displayColumn(int eye, int x) { + (void)eye; + if (!scratch) + return NULL; + const int c = x % stripeW; // Column within the stripe + uint16_t *base = &scratch[(size_t)scratchIdx * stripeW * eyeSize]; + if (c == 0) + stripeBase = x; + // Bottom row of this column; the renderer walks upward by one stripe width. + return &base[(size_t)(eyeSize - 1) * stripeW + c]; +} + +void displayColumnDone(int eye, int x) { + if (!scratch) + return; +#if PROFILE_FRAME + uint32_t t0 = micros(); +#endif + // Only send once the stripe is full. + if (((x % stripeW) == (stripeW - 1)) || (x == eyeSize - 1)) { + esp_lcd_panel_draw_bitmap(panelHandle[eye], originX + stripeBase, originY, + originX + stripeBase + stripeW, originY + eyeSize, + &scratch[(size_t)scratchIdx * stripeW * eyeSize]); + pending[eye]++; + scratchIdx ^= 1; + drain(eye, 1); + } +#if PROFILE_FRAME + displayBusyMicros += micros() - t0; +#endif +} + +void displayEyeEnd(int eye) { drain(eye, 0); } +void displayFrameEnd(void) {} + +void displaySelfTest(void) { + const uint16_t idColor[2] = {0xF800, 0x001F}; + const char *idName[2] = {"RED", "BLUE"}; + for (int e = 0; e < NUM_EYES; e++) { + DBG(" self-test: panel %d -> %s (CS=GPIO%d DC=GPIO%d)\n", e, idName[e], + csPin[e], dcPin[e]); + fillPanel(e, OUT16(idColor[e])); + } + DBGLN(" if a panel stays dark, its CS/DC GPIO numbers are wrong"); + delay(2500); + for (int e = 0; e < NUM_EYES; e++) + fillPanel(e, 0); +} + +#endif // EYE_DISPLAY == EYE_DISPLAY_ESP_LCD diff --git a/Adafruit_Monster_Eyes/display_tft.cpp b/Adafruit_Monster_Eyes/display_tft.cpp new file mode 100644 index 0000000..33387a9 --- /dev/null +++ b/Adafruit_Monster_Eyes/display_tft.cpp @@ -0,0 +1,394 @@ +/** + * @file display_tft.cpp + * @brief Display backend: SPI TFT panels driven by Adafruit_GFX. + * + * Portable across RP2 and ESP32. On RP2 it additionally batches pixels + * straight into the SPI hardware and drives them with DMA, so transfers + * overlap the next column's render. + * + * Inert unless @ref EYE_DISPLAY selects it, so other builds need no TFT + * driver library. + * + * COLUMN ORDER. The renderer's +Y is up, so it produces a column bottom-first + * while the panel wants it top-first. Rather than reverse it in a second pass, + * displayColumn() hands back a pointer near the END of the stripe buffer with + * a negative stride; the renderer then fills it in exactly the order the panel + * wants. This is verified to be pixel-identical to the framebuffer path. + */ + +#include "eye.h" + +#if EYE_DISPLAY == EYE_DISPLAY_TFT + +#include +#include +#if TFT_FAST_SPI_ACTIVE +#include +#if TFT_DMA +#include +#endif +#endif + +#if TFT_DRIVER == TFT_DRIVER_ST7789 +#include +/** Adafruit driver class for the selected panel */ +#define TFT_CLASS Adafruit_ST7789 +static Adafruit_ST7789 tft0(&TFT_SPI_PORT, TFT_CS, TFT_DC, -1); +#if NUM_EYES > 1 +static Adafruit_ST7789 tft1(&TFT1_SPI_PORT, TFT1_CS, TFT1_DC, -1); +#endif +#elif TFT_DRIVER == TFT_DRIVER_ILI9341 +#include +/** Adafruit driver class for the selected panel */ +#define TFT_CLASS Adafruit_ILI9341 +static Adafruit_ILI9341 tft0(&TFT_SPI_PORT, TFT_DC, TFT_CS, -1); +#if NUM_EYES > 1 +static Adafruit_ILI9341 tft1(&TFT1_SPI_PORT, TFT1_DC, TFT1_CS, -1); +#endif +#elif TFT_DRIVER == TFT_DRIVER_GC9A01A +#include +/** Adafruit driver class for the selected panel */ +#define TFT_CLASS Adafruit_GC9A01A +static Adafruit_GC9A01A tft0(&TFT_SPI_PORT, TFT_DC, TFT_CS, -1); +#if NUM_EYES > 1 +static Adafruit_GC9A01A tft1(&TFT1_SPI_PORT, TFT1_DC, TFT1_CS, -1); +#endif +#else +#error "Unknown TFT_DRIVER" +#endif + +#if TFT_FAST_SPI_ACTIVE +static spi_inst_t *spiInst[NUM_EYES]; +static spi_inst_t *spiForPin(int sck) { + return (((sck / 8) % 2) == 0) ? spi0 : spi1; +} + +#if !TFT_DMA +static void pushPixels(spi_inst_t *spi, const uint16_t *buf, size_t n) { + while (spi_is_busy(spi)) + tight_loop_contents(); // let commands finish + spi_set_format(spi, 16, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST); + + size_t tx = n, rx = n; + uint32_t guard = 0; + const uint32_t guardMax = (uint32_t)n * 64 + 10000; + while ((tx || rx) && (++guard < guardMax)) { + if (tx && spi_is_writable(spi)) { + spi_get_hw(spi)->dr = *buf++; + tx--; + } + if (rx && spi_is_readable(spi)) { + (void)spi_get_hw(spi)->dr; + rx--; + } + } + (void)guardMax; + if (guard >= guardMax) { + static bool warned = false; + if (!warned) { + warned = true; + Serial.println( + "SPI burst stalled -- wrong SPI block? Set TFT_FAST_SPI 0."); + } + } + while (spi_is_busy(spi)) + tight_loop_contents(); + spi_set_format(spi, 8, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST); +} +#endif // !TFT_DMA +#endif + +static TFT_CLASS *panel[NUM_EYES] = {&tft0 +#if NUM_EYES > 1 + , + &tft1 +#endif +}; + +// Two column buffers per eye when DMA is on: the renderer fills one while the +// other is still being sent. Without DMA a single buffer is enough. +#if TFT_FAST_SPI_ACTIVE && TFT_DMA +/** Column buffers; 2 lets a DMA transfer overlap rendering */ +#define COLUMN_BUFFERS 2 +#else +/** Column buffers; 2 lets a DMA transfer overlap rendering */ +#define COLUMN_BUFFERS 1 +#endif +static const int csPin[NUM_EYES] = {TFT_CS +#if NUM_EYES > 1 + , + TFT1_CS +#endif +}; +static const int dcPin[NUM_EYES] = {TFT_DC +#if NUM_EYES > 1 + , + TFT1_DC +#endif +}; +static const int rstPin[NUM_EYES] = {TFT_RST +#if NUM_EYES > 1 + , + TFT1_RST +#endif +}; + +// STRIPE BUFFERING +static uint16_t *scratch = NULL; // COLUMN_BUFFERS * stripeW * eyeSize +static uint8_t scratchIdx = 0; +static int stripeW = 1; // Columns per address window +static int stripeBase = 0; // First column of the stripe in hand + +#if TFT_FAST_SPI_ACTIVE && TFT_DMA +static int dmaCh[NUM_EYES]; +static bool dmaActive[NUM_EYES]; + +// Wait for this eye's transfer to land +static void dmaSettle(int eye) { + if (!dmaActive[eye]) + return; + uint32_t guard = 0; + while (dma_channel_is_busy(dmaCh[eye]) && (++guard < 5000000)) + tight_loop_contents(); + if (guard >= 5000000) { + static bool warned = false; + if (!warned) { + warned = true; + Serial.println("DMA stalled -- set TFT_DMA 0 in eye.h."); + } + dma_channel_abort(dmaCh[eye]); + } + dmaActive[eye] = false; + // The channel is done feeding the FIFO; the shifter may still be draining. + while (spi_is_busy(spiInst[eye])) + tight_loop_contents(); +} +#endif +static int panelW = 0, panelH = 0; +static int originX = 0, originY = 0, eyeSize = 0; + +int displayColumnStride = -1; ///< Row-to-row step in the buffer +volatile uint32_t displayBusyMicros = 0; ///< Time pushing pixels + +// Reset every panel ONCE, before any of them is initialized. +// + +static void initPanel(TFT_CLASS *t) { +#if TFT_DRIVER == TFT_DRIVER_ST7789 + t->init(TFT_W, TFT_H); +#else + t->begin(); +#endif + t->setSPISpeed(TFT_SPI_HZ); + t->setRotation(TFT_ROTATION); +} + +template +static void beginSpiPort(SPI_T &port, int sck, int mosi) { +#if defined(ARDUINO_ARCH_RP2040) + port.setSCK(sck); + port.setTX(mosi); + port.begin(); +#elif defined(ARDUINO_ARCH_ESP32) + port.begin(sck, -1, mosi, -1); +#else + (void)sck; + (void)mosi; + port.begin(); +#endif +} + +bool displayBegin(void) { + beginSpiPort(TFT_SPI_PORT, TFT_SCK, TFT_MOSI); +#if NUM_EYES > 1 + // Only if the second panel really is on a different bus; re-initialising the + // same port would tear down the one just configured. + const void *bus0 = (const void *)&TFT_SPI_PORT; + const void *bus1 = (const void *)&TFT1_SPI_PORT; + if (bus1 != bus0) + beginSpiPort(TFT1_SPI_PORT, TFT1_SCK, TFT1_MOSI); +#endif + + eyePanelReset(rstPin, NUM_EYES); + for (int e = 0; e < NUM_EYES; e++) { + initPanel(panel[e]); + DBG(" panel %d initialised (CS=GPIO%d DC=GPIO%d)\n", e, csPin[e], + dcPin[e]); + } + +#if TFT_FAST_SPI_ACTIVE + spiInst[0] = spiForPin(TFT_SCK); + DBG("fast SPI: panel 0 on spi%d (SCK pin %d)\n", (spiInst[0] == spi0) ? 0 : 1, + TFT_SCK); +#if NUM_EYES > 1 + spiInst[1] = spiForPin(TFT1_SCK); + DBG("fast SPI: panel 1 on spi%d (SCK pin %d)\n", (spiInst[1] == spi0) ? 0 : 1, + TFT1_SCK); +#endif + +#if TFT_DMA + for (int e = 0; e < NUM_EYES; e++) { + dmaCh[e] = dma_claim_unused_channel(false); + if (dmaCh[e] < 0) { + Serial.println("No free DMA channel; falling back to CPU bursts."); + } else { + dma_channel_config c = dma_channel_get_default_config(dmaCh[e]); + channel_config_set_transfer_data_size(&c, DMA_SIZE_16); + channel_config_set_read_increment(&c, true); + channel_config_set_write_increment(&c, false); + channel_config_set_dreq(&c, spi_get_dreq(spiInst[e], true)); + dma_channel_configure(dmaCh[e], &c, &spi_get_hw(spiInst[e])->dr, NULL, 0, + false); + DBG("DMA: panel %d on channel %d\n", e, dmaCh[e]); + } + dmaActive[e] = false; + } +#endif +#endif + + panelW = panel[0]->width(); + panelH = panel[0]->height(); + +#if TFT_BACKLIGHT >= 0 + pinMode(TFT_BACKLIGHT, OUTPUT); + digitalWrite(TFT_BACKLIGHT, HIGH); +#endif + + DBG("TFT up: %d panel(s), %dx%d at %ld Hz\n", NUM_EYES, panelW, panelH, + (long)TFT_SPI_HZ); + return (panelW > 0) && (panelH > 0); +} + +int displayMaxEyeSize(void) { return (panelW < panelH) ? panelW : panelH; } + +void displaySetEyeSize(int size) { + eyeSize = size; + originX = (panelW - size) / 2; + originY = (panelH - size) / 2; + if (originX < 0) + originX = 0; + if (originY < 0) + originY = 0; + + // A partial stripe would leave the buffer rows non-contiguous, so pick the + // widest stripe that divides the eye exactly. + stripeW = 1; + for (int w = TFT_STRIPE_COLS; w >= 1; w--) { + if ((size % w) == 0) { + stripeW = w; + break; + } + } + displayColumnStride = -stripeW; + + free(scratch); + scratch = (uint16_t *)eyeMalloc((size_t)stripeW * size * COLUMN_BUFFERS * + sizeof(uint16_t)); + scratchIdx = 0; + stripeBase = 0; + if (scratch) { + DBG(" stripes: %d columns, %d windows per eye, %u bytes\n", stripeW, + size / stripeW, + (unsigned)((size_t)stripeW * size * COLUMN_BUFFERS * 2)); + } + if (!scratch) + Serial.println("Column buffer allocation failed!"); +} + +void displayClear(uint16_t color) { + for (int e = 0; e < NUM_EYES; e++) + panel[e]->fillScreen(color); +} + +void displayFrameBegin(void) {} + +// startWrite / endWrite bracket each eye rather than the whole frame, +void displayEyeBegin(int eye) { panel[eye]->startWrite(); } + +void displayEyeEnd(int eye) { +#if TFT_FAST_SPI_ACTIVE && TFT_DMA + dmaSettle(eye); // The last column of the eye must land before CS releases + spi_set_format(spiInst[eye], 8, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST); +#endif + panel[eye]->endWrite(); +} + +uint16_t *displayColumn(int eye, int x) { + (void)eye; + if (!scratch) + return NULL; + const int c = x % stripeW; // Column within the stripe + uint16_t *base = &scratch[(size_t)scratchIdx * stripeW * eyeSize]; + if (c == 0) + stripeBase = x; + // Bottom row of this column; the renderer walks up by one stripe width. + return &base[(size_t)(eyeSize - 1) * stripeW + c]; +} + +void displayColumnDone(int eye, int x) { + if (!scratch) + return; + // Nothing leaves until the stripe is full. + if (((x % stripeW) != (stripeW - 1)) && (x != eyeSize - 1)) + return; + +#if PROFILE_FRAME + uint32_t t0 = micros(); +#endif + uint16_t *buf = &scratch[(size_t)scratchIdx * stripeW * eyeSize]; + const uint32_t count = (uint32_t)stripeW * eyeSize; + +#if TFT_FAST_SPI_ACTIVE && TFT_DMA + dmaSettle(eye); + spi_set_format(spiInst[eye], 8, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST); + panel[eye]->setAddrWindow(originX + stripeBase, originY, stripeW, eyeSize); + spi_set_format(spiInst[eye], 16, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST); + dma_channel_transfer_from_buffer_now(dmaCh[eye], buf, count); + dmaActive[eye] = true; + +#elif TFT_FAST_SPI_ACTIVE + panel[eye]->setAddrWindow(originX + stripeBase, originY, stripeW, eyeSize); + pushPixels(spiInst[eye], buf, count); + +#else + panel[eye]->setAddrWindow(originX + stripeBase, originY, stripeW, eyeSize); + panel[eye]->writePixels(buf, count, true, false); +#endif + + scratchIdx ^= 1; +#if PROFILE_FRAME + displayBusyMicros += micros() - t0; +#endif +} + +void displayFrameEnd(void) {} + +void displaySelfTest(void) { + const uint16_t idColor[2] = {0xF800, 0x001F}; // panel 0 red, panel 1 blue + const char *idName[2] = {"RED", "BLUE"}; + (void)idName; + + for (int e = 0; e < NUM_EYES; e++) { + DBG(" self-test: panel %d -> %s (CS=GPIO%d DC=GPIO%d RST=GPIO%d)\n", e, + idName[e], csPin[e], dcPin[e], rstPin[e]); + panel[e]->fillScreen(idColor[e]); + } + DBGLN(" if a panel stays dark, its CS/DC/RST GPIO numbers are wrong"); + delay(2500); + + const uint16_t bars[3] = {0x07E0, 0xFFFF, 0x0000}; + for (uint8_t i = 0; i < 3; i++) { + for (int e = 0; e < NUM_EYES; e++) + panel[e]->fillScreen(bars[i]); + delay(400); + } + + for (int e = 0; e < NUM_EYES; e++) { + panel[e]->fillScreen(0); + panel[e]->fillRect(e ? panelW / 2 : 0, 0, panelW / 2, panelH / 2, 0xFFE0); + } + DBGLN(" yellow block: TOP-LEFT on panel 0, TOP-RIGHT on panel 1"); + delay(1500); +} + +#endif // EYE_DISPLAY == EYE_DISPLAY_TFT diff --git a/Adafruit_Monster_Eyes/eye.h b/Adafruit_Monster_Eyes/eye.h new file mode 100644 index 0000000..4cfe59e --- /dev/null +++ b/Adafruit_Monster_Eyes/eye.h @@ -0,0 +1,529 @@ +/** + * @file eye.h + * @brief Internal wiring: turns user settings into a concrete backend. + * + * You should not need to edit this file. User choices live in @ref settings.h + * and per-board defaults in @ref platform.h; this header resolves those into + * the macros and declarations the .cpp files compile against. + * + * It also declares the display backend interface that @ref display_tft.cpp, + * @ref display_esp_lcd.cpp and @ref display_dvi.cpp each implement. + */ + +#pragma once +#include "platform.h" +#include "settings.h" +#include +#include + +// =========================================================================== +// BACKEND DERIVATION +// =========================================================================== +// +// One panel choice in settings.h becomes one backend here. The rule: +// +// DVI -> PicoDVI (RP2 only) +// ST7789 / GC9A01A on ESP32 -> esp_lcd (DMA, fastest) +// anything else -> Adafruit_GFX (portable) +// +// EYE_FORCE_ADAFRUIT_BACKEND overrides the middle rule, and ILI9341 falls +// through to Adafruit automatically because ESP-IDF has no core driver for it. + +// Verbose output, compiled out entirely when EYE_DEBUG is 0. Genuine failures +// use Serial directly so they are reported either way. +#if EYE_DEBUG +/** + * @brief Print a formatted diagnostic line; compiled out when EYE_DEBUG is 0. + * @param ... printf-style format string and arguments. + */ +#define DBG(...) Serial.printf(__VA_ARGS__) +/** + * @brief Print a diagnostic line; compiled out when EYE_DEBUG is 0. + * @param s Text to print. + */ +#define DBGLN(s) Serial.println(s) +#else +/** + * @brief No-op form of DBG(); EYE_DEBUG is 0. + * @param ... Ignored. + */ +#define DBG(...) \ + do { \ + } while (0) +/** + * @brief No-op form of DBGLN(); EYE_DEBUG is 0. + * @param s Ignored. + */ +#define DBGLN(s) \ + do { \ + } while (0) +#endif + +#define EYE_DISPLAY_DVI 0 ///< Backend: PicoDVI framebuffer +#define EYE_DISPLAY_TFT 1 ///< Backend: Adafruit_GFX SPI TFT +#define EYE_DISPLAY_ESP_LCD 2 ///< Backend: ESP-IDF esp_lcd with DMA + +// Resolve the auto sentinels now that platform.h has supplied the board +// profile. NUM_EYES needs this indirection because a profile cannot simply +// #define it -- settings.h is read first, so its value would already be set. +#if NUM_EYES == 0 +#undef NUM_EYES +#define NUM_EYES EYE_BOARD_DEFAULT_EYES ///< Eye count from the board profile +#endif + +// Resolve EYE_PANEL_AUTO against the board profile. +#if EYE_PANEL == EYE_PANEL_AUTO +#undef EYE_PANEL +/** Which panel is attached; see the EYE_PANEL_* values */ +#define EYE_PANEL EYE_PANEL_DEFAULT +#endif + +#if EYE_PANEL == EYE_PANEL_DVI +#if !defined(ARDUINO_ARCH_RP2040) +#error "EYE_PANEL_DVI needs an RP2040 or RP2350 -- PicoDVI is RP2 only." +#endif +#define EYE_DISPLAY EYE_DISPLAY_DVI ///< Backend chosen from the panel and chip +#elif defined(ARDUINO_ARCH_ESP32) && !EYE_FORCE_ADAFRUIT_BACKEND && \ + ((EYE_PANEL == EYE_PANEL_ST7789) || (EYE_PANEL == EYE_PANEL_GC9A01A)) +/** Backend chosen from the panel and chip */ +#define EYE_DISPLAY EYE_DISPLAY_ESP_LCD +#else +#define EYE_DISPLAY EYE_DISPLAY_TFT ///< Backend chosen from the panel and chip +#endif + +// Controller selection for whichever backend won. +#define TFT_DRIVER_ST7789 0 ///< Adafruit driver: ST7789 +#define TFT_DRIVER_ILI9341 1 ///< Adafruit driver: ILI9341 +#define TFT_DRIVER_GC9A01A 2 ///< Adafruit driver: GC9A01A + +#if EYE_PANEL == EYE_PANEL_GC9A01A +/** Adafruit driver matching the selected panel */ +#define TFT_DRIVER TFT_DRIVER_GC9A01A +#elif EYE_PANEL == EYE_PANEL_ILI9341 +/** Adafruit driver matching the selected panel */ +#define TFT_DRIVER TFT_DRIVER_ILI9341 +#else +/** Adafruit driver matching the selected panel */ +#define TFT_DRIVER TFT_DRIVER_ST7789 +#endif + +// ESP-IDF ships an ST7789 panel driver in core. GC9A01A is not in core, so the +// esp_lcd backend creates an ST7789 panel object -- the addressing commands are +// identical -- and sends the GC9A01A vendor init sequence itself. +#define ESP_LCD_DRV_ST7789 0 ///< esp_lcd panel: ST7789, shipped in ESP-IDF core +#define ESP_LCD_DRV_GC9A01A 1 ///< esp_lcd panel: GC9A01A, vendor init sent here + +#if EYE_PANEL == EYE_PANEL_GC9A01A +/** esp_lcd panel matching the selected panel */ +#define ESP_LCD_DRIVER ESP_LCD_DRV_GC9A01A +#else +/** esp_lcd panel matching the selected panel */ +#define ESP_LCD_DRIVER ESP_LCD_DRV_ST7789 +#endif + +#ifndef ESP_LCD_HOST +#define ESP_LCD_HOST SPI2_HOST ///< SPI host the esp_lcd backend drives +#endif + +// Framebuffer geometry, DVI only. +#define FB_WIDTH 320 ///< DVI framebuffer width in pixels +#define FB_HEIGHT 240 ///< DVI framebuffer height in pixels + +// Is the RP2 batched-SPI burst actually in play? +#if (EYE_DISPLAY == EYE_DISPLAY_TFT) && TFT_FAST_SPI && \ + defined(ARDUINO_ARCH_RP2040) +/** 1 when the RP2 batched-SPI burst is compiled in */ +#define TFT_FAST_SPI_ACTIVE 1 +#else +/** 1 when the RP2 batched-SPI burst is compiled in */ +#define TFT_FAST_SPI_ACTIVE 0 +#endif + +// Byte order the render loop writes in. It costs nothing either way -- the +// swap happens once per texture at load and once per colour per frame -- so +// each backend asks for whatever its output path wants: +// +// RP2 burst native; 16-bit MSB-first frames emit the high byte first +// Adafruit TFT native; the driver swaps, and that branch measured faster +// DVI native; GFXcanvas16 is native uint16_t +// esp_lcd BIG-ENDIAN; the buffer goes to DMA verbatim +#if EYE_DISPLAY == EYE_DISPLAY_ESP_LCD +#define DISPLAY_BIG_ENDIAN 1 ///< 1 when the backend wants byte-swapped pixels +/** + * @brief Convert a colour to the byte order this backend wants: byte-swapped + * for the wire. + * @param v Native-endian RGB565 colour. + */ +#define OUT16(v) __builtin_bswap16((uint16_t)(v)) +#else +#define DISPLAY_BIG_ENDIAN 0 ///< 1 when the backend wants byte-swapped pixels +/** + * @brief Convert a colour to the byte order this backend wants: unchanged. + * @param v Native-endian RGB565 colour. + */ +#define OUT16(v) ((uint16_t)(v)) +#endif + +// =========================================================================== +// DISPLAY BACKEND +// =========================================================================== +// +// Contract, per frame: +// +// displayFrameBegin(); +// for (e = 0; e < NUM_EYES; e++) { +// displayEyeBegin(e); +// for (x = 0; x < size; x++) { +// uint16_t *p = displayColumn(e, x); +// // write exactly `size` pixels, advancing p by displayColumnStride +// displayColumnDone(e, x); +// } +// displayEyeEnd(e); +// } +// displayFrameEnd(); +// +// The stride lets each backend choose its own memory layout and direction: +// the DVI backend hands back a pointer into the framebuffer with a negative +// row stride, while the TFT backend hands back a scratch buffer written in +// reverse so it can be shipped out top-to-bottom without a second pass. + +/** + * @brief Pulse every distinct reset pin once, before any panel is initialised. + * + * Panels are constructed without a reset pin so that no driver pulses the line + * itself. With a SHARED reset, the second panel's init would otherwise knock + * the first back to its power-on state -- the symptom being a panel that + * flashes an image at boot and then stays dark while still receiving pixels. + * + * Duplicate pin numbers are pulsed only once. + * + * @param rstPins Reset GPIO per eye; entries below zero are skipped. + * @param count Number of entries in @p rstPins. + */ +void eyePanelReset(const int *rstPins, int count); + +/** + * @brief Bring up every display surface. + * @return true on success; false leaves the sketch blinking an error. + */ +bool displayBegin(void); +/** + * @brief Largest square one eye can occupy. + * + * With two eyes sharing a single framebuffer this is half the width, so a + * config asking for more is clamped rather than overlapping its neighbour. + * + * @return Maximum eye width and height in pixels. + */ +int displayMaxEyeSize(void); +/** + * @brief Fix the eye size and allocate per-column state. + * + * Call before textures claim the heap: on TFT this allocates the stripe + * buffer, and if it were requested afterwards the texture loader could starve + * it, leaving a running frame counter and a blank screen. + * + * @param size Eye width and height in pixels. + */ +void displaySetEyeSize(int size); +/** + * @brief Fill every panel with a solid colour. + * @param color Native-endian RGB565; the backend converts if it needs to. + */ +void displayClear(uint16_t color); + +/** @brief Start a frame. */ +void displayFrameBegin(void); + +/** + * @brief Start one eye; opens the bus transaction on SPI backends. + * @param eye Eye index, 0 to NUM_EYES-1. + */ +void displayEyeBegin(int eye); + +/** + * @brief Where to write column @p x of eye @p eye. + * + * Write exactly the eye size in pixels, advancing by @ref + * displayColumnStride after each one. The stride lets each backend choose its + * own layout: the DVI backend returns a pointer into the framebuffer with a + * negative row stride, while the SPI backends return the end of a stripe + * buffer so the renderer fills it in the order the panel wants. + * + * @param eye Eye index, 0 to NUM_EYES-1. + * @param x Column index, 0 to the eye size minus one. + * @return Buffer to write into, or NULL if no buffer could be allocated. + */ +uint16_t *displayColumn(int eye, int x); + +/** + * @brief Hand a finished column back; the backend may send it or batch it. + * @param eye Eye index, 0 to NUM_EYES-1. + * @param x Column index, 0 to the eye size minus one. + */ +void displayColumnDone(int eye, int x); + +/** + * @brief Finish one eye, flushing anything still in flight. + * @param eye Eye index, 0 to NUM_EYES-1. + */ +void displayEyeEnd(int eye); + +/** @brief Finish the frame. */ +void displayFrameEnd(void); +/** + * @brief Fill panel 0 red and panel 1 blue using the driver's own fill. + * + * Answers three questions at once: is each panel responding, which physical + * display is which index, and are the chip selects independent. + */ +void displaySelfTest(void); + +/** + * @brief Repeat the fill through the actual render path. + * + * A panel dark in both tests points at wiring; dark only in this one points at + * the transfer path. + * + * @param size Eye size in pixels, as passed to displaySetEyeSize(). + */ +void displayPathTest(int size); + +/** @brief Pixels to advance between successive rows of a column. */ +extern int displayColumnStride; + +// Microseconds spent inside displayColumnDone() since last cleared, i.e. time +// pushing pixels at the panel. Zero on DVI, where a column is already in the +// framebuffer and there is nothing to push. +extern volatile uint32_t displayBusyMicros; ///< Microseconds spent pushing + +// =========================================================================== +// SETTINGS +// =========================================================================== + +#define EYE_PATH_MAX 64 ///< Longest asset path accepted from a config file + +/** @brief Everything a config.eye file can change about the eye. */ +struct EyeSettings { + int displaySize; ///< Eye width and height in pixels + int eyeRadius; ///< Eyeball radius in screen pixels + int irisRadius; ///< Iris radius in screen pixels + int slitPupilRadius; ///< Slit pupil radius; 0 gives a round pupil + float coverage; ///< Effective, possibly raised by finalize() + float coverageRequested; ///< What the config actually asked for + uint16_t pupilColor; ///< Pupil colour, native-endian RGB565 + uint16_t backColor; ///< Back-of-eye colour, seen at extreme gaze + uint16_t eyelidColor; ///< Eyelid colour + uint16_t irisColor; ///< Iris colour used when no texture loads + uint16_t scleraColor; ///< Sclera colour used when no texture loads + float pupilMin; ///< Smallest pupil as a fraction of the iris + float pupilMax; ///< Largest pupil as a fraction of the iris + bool tracking; ///< Upper lid follows the iris + float trackFactor; ///< 1.0 minus squint; how far the lid rests down + uint32_t gazeMax; ///< Longest wait between major eye movements, us + float irisSpin; ///< Iris rotation in RPM, positive is clockwise + float scleraSpin; ///< Sclera rotation in RPM + uint16_t irisStartAngle; ///< Initial iris rotation, 0-1023 CCW + uint16_t scleraStartAngle; ///< Initial sclera rotation, 0-1023 CCW + uint16_t irisMirror; ///< 0 or 1023; 1023 mirrors the iris texture + uint16_t scleraMirror; ///< 0 or 1023; 1023 mirrors the sclera texture + bool eyelidMirror; ///< Mirror the eyelid shape horizontally + char irisFile[EYE_PATH_MAX]; ///< Iris texture path on the drive + char scleraFile[EYE_PATH_MAX]; ///< Sclera texture path on the drive + char upperFile[EYE_PATH_MAX]; ///< Upper eyelid bitmap path + char lowerFile[EYE_PATH_MAX]; ///< Lower eyelid bitmap path +}; + +extern EyeSettings settings; ///< Live settings, shared by every eye + +// The handful of values that may legitimately differ between two eyes. +// Everything else -- geometry, textures, eyelid shape -- is shared, because +// there is only one set of polar maps and one copy of each texture in RAM. +// In a .eye file these come from the "right" block (eye 0) and the "left" +// block (eye 1), matching the original M4_Eyes naming, where eye 0 is the +// character's RIGHT eye and therefore appears on the viewer's LEFT. +/** @brief The few values that may differ between the two eyes. */ +struct EyeVariant { + float irisSpin; ///< Iris rotation in RPM for this eye + float scleraSpin; ///< Sclera rotation in RPM for this eye + uint16_t irisStartAngle; ///< Initial iris rotation, 0-1023 CCW + uint16_t scleraStartAngle; ///< Initial sclera rotation, 0-1023 CCW + uint16_t irisMirror; ///< 0 or 1023; 1023 mirrors the iris + uint16_t scleraMirror; ///< 0 or 1023; 1023 mirrors the sclera + bool eyelidMirror; ///< Mirror the eyelid shape for this eye +}; + +extern EyeVariant eyeVariant[NUM_EYES]; ///< Per-eye overrides + +/** @brief Populate @ref settings from the compile-time defaults. */ +void eyeSettingsDefaults(void); +/** + * @brief Overlay values from a JSON config file onto @ref settings. + * + * Missing keys keep their current value, so this is safe to call on top of the + * defaults. A single-eye build also applies the @ref EYE_SIDE block, so + * two-eye .eye files still do something sensible. + * + * @param filename Path to the JSON configuration on the drive. + * @return false if the file is absent or unparseable; settings stay usable. + */ +bool eyeSettingsLoad(const char *filename); +/** + * @brief Clamp settings, resolve auto values and guarantee a usable gaze. + * + * Resolves the 0/-1 sentinels for eye, iris and slit-pupil radius, and raises + * @c coverage if the geometry would otherwise leave the eye no room to look + * around. + */ +void eyeSettingsFinalize(void); + +// =========================================================================== +// STORAGE +// =========================================================================== + +/** + * @brief Mount the FAT volume holding config.eye and the bitmaps. + * @return true if the volume mounted; false leaves built-in defaults in use. + */ +bool eyeStorageBegin(void); +/** @brief Stop reading the filesystem so flash stays quiet while rendering. */ +void eyeStorageEnd(void); +/** + * @brief Has the user asked for the USB drive instead of the eye? + * @return true if the safe-mode button or BOOTSEL is held. + */ +bool eyeStorageDriveModeRequested(void); +/** + * @brief Export flash over USB and never return. + * + * DVI and the panels are not started in this mode -- see the comment in + * eye_support.cpp for why writing flash and driving a display cannot overlap. + * Reboots once host writes go quiet. + */ +void eyeStorageRunDriveMode(void); + +// =========================================================================== +// TABLES +// =========================================================================== + +extern uint8_t *displace; ///< (size/2)^2 quadrant; 255 = outside eyeball +extern uint8_t *polarAngle; ///< mapRadius^2 quadrant of angles +extern int8_t *polarDist; ///< mapRadius^2; >=0 sclera, <0 iris, -128 off +extern int mapRadius; ///< Polar map radius in map pixels +extern int mapDiameter; ///< Twice mapRadius, for bounds checks + +/** + * @brief Build the polar and displacement maps from the current settings. + * @return false if allocation failed; the caller may shrink the eye and retry. + */ +bool eyeTablesInit(void); +/** @brief Release the polar and displacement maps. */ +void eyeTablesFree(void); +/** + * @brief Convert a length in screen pixels to polar-map pixels. + * @param in Length in screen pixels. + * @return The equivalent length in polar-map pixels. + */ +float screen2map(int in); +/** + * @brief Inverse of screen2map(). + * @param in Length in polar-map pixels. + * @return The equivalent length in screen pixels. + */ +float map2screen(int in); + +// =========================================================================== +// MEDIA +// =========================================================================== + +extern uint8_t *upperOpen; ///< Upper lid position per column, fully open +extern uint8_t *upperClosed; ///< Upper lid position per column, fully shut +extern uint8_t *lowerOpen; ///< Lower lid position per column, fully open +extern uint8_t *lowerClosed; ///< Lower lid position per column, fully shut +extern const uint16_t *irisData; ///< Iris texture, or a 1x1 solid colour +extern const uint16_t *scleraData; ///< Sclera texture, or a 1x1 solid colour + +/** @brief Iris texture width in pixels. @return Width, at least 1. */ +uint16_t irisWidth(void); +/** @brief Iris texture height in pixels. @return Height, at least 1. */ +uint16_t irisHeight(void); +/** @brief Sclera texture width in pixels. @return Width, at least 1. */ +uint16_t scleraWidth(void); +/** @brief Sclera texture height in pixels. @return Height, at least 1. */ +uint16_t scleraHeight(void); + +/** + * @brief Load eyelid tables and textures for an eye of @p size pixels. + * + * Every asset is optional. A missing texture becomes a 1x1 solid colour, which + * the renderer samples correctly and which still yields a properly sized, + * dilating pupil; a missing eyelid leaves the sweep tables at their init + * values, which reads as no eyelid. + * + * @param size Eye size in pixels. + * @param texBudget Bytes textures may consume in total. Oversized images are + * decimated to fit rather than rejected. + * @return false only if the eyelid tables could not be allocated. + */ +bool eyeMediaLoad(int size, uint32_t texBudget); + +// =========================================================================== +// BMP +// =========================================================================== + +/** + * @brief Byte source for the BMP loaders. + * + * Abstracting this keeps the loaders testable on a host and independent of + * whichever filesystem the board happens to use. + */ +class BmpReader { +public: + virtual ~BmpReader() {} + /** + * @brief Move to an absolute byte offset. + * @param pos Offset from the start of the file. + * @return true if the seek succeeded. + */ + virtual bool seek(uint32_t pos) = 0; + /** + * @brief Read bytes from the current position. + * @param buf Destination buffer. + * @param len Bytes requested. + * @return Bytes actually read; 0 on failure. + */ + virtual size_t read(void *buf, size_t len) = 0; +}; + +/** + * @brief Load a 1-bit eyelid bitmap into a pair of sweep tables. + * + * For each column the topmost and bottommost lit pixel become the fully open + * and fully shut lid positions, flipped into the renderer's frame where +Y is + * up. The image is scaled to @p size, so any source dimensions work. + * + * @param r Source of BMP bytes. + * @param openTable Receives the fully-open position per column. + * @param closedTable Receives the fully-shut position per column. + * @param size Eye size in pixels; both tables hold this many entries. + * @param isUpper true for the upper lid, false for the lower. + * @return false if the image is not an uncompressed 1-bit BMP. + */ +bool bmpLoadEyelid(BmpReader &r, uint8_t *openTable, uint8_t *closedTable, + int size, bool isUpper); +/** + * @brief Load a 24-bit BMP as an RGB565 texture, decimating it to fit. + * + * Oversized images lose resolution rather than being rejected, so any source + * works on any board. + * + * @param r Source of BMP bytes. + * @param data Receives a malloc'd buffer the caller owns. + * @param width Receives the resulting width. + * @param height Receives the resulting height. + * @param maxBytes Largest buffer the caller can afford. + * @return false if the image is not an uncompressed 24-bit BMP, or if even + * the smallest decimation exceeds @p maxBytes. + */ +bool bmpLoadTexture(BmpReader &r, uint16_t **data, uint16_t *width, + uint16_t *height, uint32_t maxBytes); diff --git a/Adafruit_Monster_Eyes/eye_support.cpp b/Adafruit_Monster_Eyes/eye_support.cpp new file mode 100644 index 0000000..f3a5c9c --- /dev/null +++ b/Adafruit_Monster_Eyes/eye_support.cpp @@ -0,0 +1,1038 @@ +/** + * @file eye_support.cpp + * @brief BMP loading, config parsing, storage, table generation and media. + * + * Sections, in order: + * 1. BMP loading -- streaming, 1-bit eyelids and 24-bit textures + * 2. Settings -- config.eye JSON + * 3. Storage -- FatFS mount and USB drive mode + * 4. Tables -- polar and displacement maps + * 5. Media -- ties files to the renderer, with solid-colour fallback + * + * @note Requires ArduinoJson 7.x (JsonDocument). On 6.x the declaration in + * eyeSettingsLoad() becomes StaticJsonDocument<2048>. + */ +#define ARDUINOJSON_ENABLE_COMMENTS 1 ///< Allow // comments inside config.eye +#include "SdFat_Adafruit_Fork.h" +#include "eye.h" +#include "flash_config.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// =========================================================================== +// 1. BMP LOADING +// =========================================================================== +// +// Output is native-endian RGB565 to match GFXcanvas16. + +// Angular resolution past 512 is wasted: the renderer indexes as +// (angle * width / 1024) with angle 0-1023, and distance is 0-127. +#define TEX_MAX_W 512 ///< Widest texture the renderer can address +#define TEX_MAX_H 128 ///< Tallest texture the renderer can address + +/** @brief Header fields the BMP loaders need from a bitmap. */ +struct BmpInfo { + int32_t width; ///< Image width in pixels + int32_t height; ///< Image height, always positive; see #topDown + uint16_t bpp; ///< Bits per pixel; only 1 and 24 are supported + uint32_t dataOffset; ///< Byte offset of the first pixel row + uint32_t rowSize; ///< Bytes per row, padded to a 4-byte boundary + bool topDown; ///< true if rows are stored first-to-last + uint8_t whiteIndex; ///< 1-bit only: the lighter of the two palette entries +}; + +static uint16_t rd16(const uint8_t *p) { return p[0] | (p[1] << 8); } +static uint32_t rd32(const uint8_t *p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | + ((uint32_t)p[3] << 24); +} + +static bool bmpReadHeader(BmpReader &r, BmpInfo &info) { + uint8_t hdr[54]; + if (!r.seek(0)) + return false; + if (r.read(hdr, sizeof(hdr)) != sizeof(hdr)) + return false; + if ((hdr[0] != 'B') || (hdr[1] != 'M')) + return false; + + info.dataOffset = rd32(&hdr[10]); + uint32_t dibSize = rd32(&hdr[14]); + int32_t w = (int32_t)rd32(&hdr[18]); + int32_t h = (int32_t)rd32(&hdr[22]); + info.bpp = rd16(&hdr[28]); + uint32_t compression = rd32(&hdr[30]); + + if (dibSize < 40) + return false; // BITMAPINFOHEADER+ + if (compression != 0) + return false; // BI_RGB only + if ((info.bpp != 1) && (info.bpp != 24)) + return false; + if (w <= 0) + return false; + + info.topDown = (h < 0); // Negative height means top-down rows + info.height = info.topDown ? -h : h; + info.width = w; + if (info.height <= 0) + return false; + + info.rowSize = (((uint32_t)w * info.bpp + 31) / 32) * 4; // 4-byte padded + + info.whiteIndex = 1; + if (info.bpp == 1) { + uint8_t pal[8]; // 2 entries, each B,G,R,reserved + if (!r.seek(14 + dibSize)) + return false; + if (r.read(pal, sizeof(pal)) != sizeof(pal)) + return false; + int lum0 = pal[0] + pal[1] + pal[2]; + int lum1 = pal[4] + pal[5] + pal[6]; + info.whiteIndex = (lum1 > lum0) ? 1 : 0; + } + return true; +} + +// Mirrors loadEyelid() in the original file.cpp: per column, find the topmost +// and bottommost lit pixel, then flip into render space where +Y is up. +// Unlike the original, which centered and CLIPPED against a fixed 240px +// screen, this scales proportionally so any source size fits any eye size. +bool bmpLoadEyelid(BmpReader &r, uint8_t *openTable, uint8_t *closedTable, + int size, bool isUpper) { + BmpInfo info; + if (!bmpReadHeader(r, info)) + return false; + if (info.bpp != 1) + return false; + if (info.height < 2) + return false; + + const uint8_t init = isUpper ? (uint8_t)(size - 1) : 0; + memset(openTable, init, size); + memset(closedTable, init, size); + + uint16_t *minRow = (uint16_t *)malloc((size_t)size * 2 * sizeof(uint16_t)); + if (!minRow) + return false; + uint16_t *maxRow = &minRow[size]; + for (int i = 0; i < size; i++) { + minRow[i] = 0xFFFF; + maxRow[i] = 0; + } + + uint8_t *row = (uint8_t *)malloc(info.rowSize); + if (!row) { + free(minRow); + return false; + } + + bool ok = true; + for (int32_t fileRow = 0; fileRow < info.height; fileRow++) { + if (!r.seek(info.dataOffset + (uint32_t)fileRow * info.rowSize) || + (r.read(row, info.rowSize) != info.rowSize)) { + ok = false; + break; + } + + // Bottom-up is the BMP default: file row 0 is the image's last row + int32_t imageRow = info.topDown ? fileRow : (info.height - 1 - fileRow); + + for (int32_t sx = 0; sx < info.width; sx++) { + uint8_t bit = (row[sx >> 3] >> (7 - (sx & 7))) & 1; + if (bit != info.whiteIndex) + continue; + int dx = (int)((int64_t)sx * size / info.width); + if (dx < 0) + dx = 0; + else if (dx >= size) + dx = size - 1; + if ((uint16_t)imageRow < minRow[dx]) + minRow[dx] = (uint16_t)imageRow; + if ((uint16_t)imageRow > maxRow[dx]) + maxRow[dx] = (uint16_t)imageRow; + } + } + free(row); + + if (ok) { + for (int dx = 0; dx < size; dx++) { + if (minRow[dx] == 0xFFFF) + continue; // No data; keep the init value + int my = (int)((int64_t)minRow[dx] * (size - 1) / (info.height - 1)); + int My = (int)((int64_t)maxRow[dx] * (size - 1) / (info.height - 1)); + if (my < 0) + my = 0; + else if (my > size - 1) + my = size - 1; + if (My < 0) + My = 0; + else if (My > size - 1) + My = size - 1; + if (isUpper) { + openTable[dx] = (uint8_t)(size - 1 - my); + closedTable[dx] = (uint8_t)(size - 1 - My); + } else { + closedTable[dx] = (uint8_t)(size - 1 - my); + openTable[dx] = (uint8_t)(size - 1 - My); + } + } + } + free(minRow); + return ok; +} + +bool bmpLoadTexture(BmpReader &r, uint16_t **data, uint16_t *width, + uint16_t *height, uint32_t maxBytes) { + BmpInfo info; + if (!bmpReadHeader(r, info)) + return false; + if (info.bpp != 24) + return false; + + // Start at the source size capped to what the renderer can address, then + // shrink the longer dimension until it fits the budget. + int dw = (int)info.width < TEX_MAX_W ? (int)info.width : TEX_MAX_W; + int dh = (int)info.height < TEX_MAX_H ? (int)info.height : TEX_MAX_H; + while (((uint32_t)dw * dh * 2 > maxBytes) && ((dw > 8) || (dh > 4))) { + if ((dw * (int)info.height) > (dh * (int)info.width)) { + if (dw > 8) + dw--; + else + dh--; + } else { + if (dh > 4) + dh--; + else + dw--; + } + } + if ((uint32_t)dw * dh * 2 > maxBytes) + return false; + + uint16_t *dst = (uint16_t *)eyeMalloc((size_t)dw * dh * 2); + if (!dst) + return false; + uint8_t *row = (uint8_t *)malloc(info.rowSize); + if (!row) { + free(dst); + return false; + } + + bool ok = true; + for (int dy = 0; dy < dh; dy++) { + int32_t imageRow = (int32_t)((int64_t)dy * info.height / dh); + int32_t fileRow = info.topDown ? imageRow : (info.height - 1 - imageRow); + if (!r.seek(info.dataOffset + (uint32_t)fileRow * info.rowSize) || + (r.read(row, info.rowSize) != info.rowSize)) { + ok = false; + break; + } + + uint16_t *out = &dst[(size_t)dy * dw]; + for (int dx = 0; dx < dw; dx++) { + int32_t sx = (int32_t)((int64_t)dx * info.width / dw); + const uint8_t *p = &row[(size_t)sx * 3]; // Stored B, G, R + *out++ = + (uint16_t)(((p[2] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[0] >> 3)); + } + } + free(row); + if (!ok) { + free(dst); + return false; + } + + *data = dst; + *width = (uint16_t)dw; + *height = (uint16_t)dh; + return true; +} + +// =========================================================================== +// 2. SETTINGS +// =========================================================================== + +// The QSPI flash chip and the FAT volume +Adafruit_SPIFlash flash(&flashTransport); ///< Flash chip driver +FatVolume fatfs; ///< FAT volume holding config.eye and the bitmaps +static bool fsMounted = false; + +EyeSettings settings; ///< Live settings, shared by every eye +EyeVariant eyeVariant[NUM_EYES]; ///< Per-eye overrides +static void seedVariants(void); + +void eyeSettingsDefaults(void) { + memset(&settings, 0, sizeof(settings)); + settings.displaySize = DISPLAY_SIZE; + settings.eyeRadius = EYE_RADIUS; + settings.irisRadius = IRIS_RADIUS; + settings.slitPupilRadius = SLIT_PUPIL_RADIUS; + settings.coverage = COVERAGE; + settings.coverageRequested = COVERAGE; + settings.pupilColor = PUPIL_COLOR; + settings.backColor = BACK_COLOR; + settings.eyelidColor = EYELID_COLOR; + settings.irisColor = IRIS_COLOR; + settings.scleraColor = SCLERA_COLOR; + settings.pupilMin = PUPIL_MIN; + settings.pupilMax = PUPIL_MAX; + settings.tracking = TRACKING; + settings.trackFactor = TRACK_FACTOR; + settings.gazeMax = GAZE_MAX; + settings.irisSpin = IRIS_SPIN; + settings.scleraSpin = 0.0f; + settings.irisStartAngle = IRIS_START_ANGLE; + settings.scleraStartAngle = IRIS_START_ANGLE; + settings.eyelidMirror = EYELID_MIRROR; + seedVariants(); +} + +// Seed each eye's variant from the shared settings. With two eyes, eye 0 +// (the character's right, appearing on the viewer's left) is the mirror of +// eye 1: opposite iris rotation, opposite start angle, unmirrored eyelids. +static void seedVariants(void) { + for (int e = 0; e < NUM_EYES; e++) { + EyeVariant &v = eyeVariant[e]; + v.irisSpin = settings.irisSpin; + v.scleraSpin = settings.scleraSpin; + v.irisStartAngle = settings.irisStartAngle; + v.scleraStartAngle = settings.scleraStartAngle; + v.irisMirror = settings.irisMirror; + v.scleraMirror = settings.scleraMirror; + v.eyelidMirror = settings.eyelidMirror; +#if NUM_EYES > 1 + if (e == 0) { + v.irisSpin = -v.irisSpin; + v.scleraSpin = -v.scleraSpin; + v.irisStartAngle = (uint16_t)((v.irisStartAngle + 512) & 1023); + v.eyelidMirror = !v.eyelidMirror; + } +#endif + } +} + +// "Do What I Mean" decoder from the original file.cpp. Accepts 42, "0x2A", +// "0xF800", [255,0,0], ["0xFF",0,0], [1.0,0.0,0.0]. Unlike the original this +// returns NATIVE-endian RGB565. +static int32_t dwim(JsonVariantConst v, int32_t def = 0) { + if (v.is()) { + return v.as(); + } else if (v.is()) { + return (int32_t)(v.as() + 0.5f); + } else if (v.is()) { + return (int32_t)strtol(v.as(), NULL, 0); + } else if (v.is()) { + JsonArrayConst a = v.as(); + if (a.size() >= 3) { + long cc[3]; + for (uint8_t i = 0; i < 3; i++) { + if (a[i].is()) + cc[i] = a[i].as(); + else if (a[i].is()) + cc[i] = (long)(a[i].as() * 255.999f); + else if (a[i].is()) + cc[i] = strtol(a[i].as(), NULL, 0); + else + cc[i] = 0; + if (cc[i] > 255) + cc[i] = 255; + else if (cc[i] < 0) + cc[i] = 0; + } + return ((cc[0] & 0xF8) << 8) | ((cc[1] & 0xFC) << 3) | (cc[2] >> 3); + } + if (a.size() >= 1) { + if (a[0].is()) + return a[0].as(); + return strtol(a[0].as(), NULL, 0); + } + } + return def; +} + +static void copyStr(char *dst, JsonVariantConst v) { + if (v.is()) { + strncpy(dst, v.as(), EYE_PATH_MAX - 1); + dst[EYE_PATH_MAX - 1] = 0; + } +} + +// Apply one JSON object: the document root, or a per-eye sub-object on top. +static void applyObject(JsonVariantConst o) { + if (o.isNull()) + return; + JsonVariantConst v; + + settings.displaySize = dwim(o["displaySize"], settings.displaySize); + settings.eyeRadius = dwim(o["eyeRadius"], settings.eyeRadius); + settings.irisRadius = dwim(o["irisRadius"], settings.irisRadius); + settings.slitPupilRadius = + dwim(o["slitPupilRadius"], settings.slitPupilRadius); + settings.gazeMax = (uint32_t)dwim(o["gazeMax"], (int32_t)settings.gazeMax); + + v = o["coverage"]; + if (v.is() || v.is()) { + settings.coverage = v.as(); + settings.coverageRequested = settings.coverage; + } + + settings.pupilColor = (uint16_t)dwim(o["pupilColor"], settings.pupilColor); + settings.backColor = (uint16_t)dwim(o["backColor"], settings.backColor); + settings.irisColor = (uint16_t)dwim(o["irisColor"], settings.irisColor); + settings.scleraColor = (uint16_t)dwim(o["scleraColor"], settings.scleraColor); + + // Legacy eyelidIndex expands to a gray via index * 0x0101, which is + // byte-symmetric and so survives the endianness change untouched. A full + // 16-bit eyelidColor is also accepted now that the byte-repeat trick the + // SPI-DMA path relied on is gone. + v = o["eyelidIndex"]; + if (!v.isNull()) + settings.eyelidColor = (uint16_t)(dwim(v) & 0xFF) * 0x0101; + v = o["eyelidColor"]; + if (!v.isNull()) + settings.eyelidColor = (uint16_t)dwim(v, settings.eyelidColor); + + v = o["pupilMin"]; + if (v.is() || v.is()) + settings.pupilMin = v.as(); + v = o["pupilMax"]; + if (v.is() || v.is()) + settings.pupilMax = v.as(); + v = o["tracking"]; + if (v.is()) + settings.tracking = v.as(); + v = o["squint"]; + if (v.is() || v.is()) + settings.trackFactor = 1.0f - v.as(); + + v = o["irisSpin"]; + if (v.is() || v.is()) + settings.irisSpin = v.as(); + v = o["scleraSpin"]; + if (v.is() || v.is()) + settings.scleraSpin = v.as(); + + v = o["irisAngle"]; + if (v.is()) + settings.irisStartAngle = 1023 - (v.as() & 1023); + else if (v.is()) + settings.irisStartAngle = 1023 - ((int)(v.as() * 1024.0f) & 1023); + v = o["scleraAngle"]; + if (v.is()) + settings.scleraStartAngle = 1023 - (v.as() & 1023); + else if (v.is()) + settings.scleraStartAngle = 1023 - ((int)(v.as() * 1024.0f) & 1023); + + v = o["irisMirror"]; + if (v.is() || v.is()) + settings.irisMirror = v.as() ? 1023 : 0; + v = o["scleraMirror"]; + if (v.is() || v.is()) + settings.scleraMirror = v.as() ? 1023 : 0; + v = o["eyelidMirror"]; + if (v.is() || v.is()) + settings.eyelidMirror = v.as(); + + copyStr(settings.irisFile, o["irisTexture"]); + copyStr(settings.scleraFile, o["scleraTexture"]); + copyStr(settings.upperFile, o["upperEyelid"]); + copyStr(settings.lowerFile, o["lowerEyelid"]); +} + +// Only the values that may legitimately differ between two eyes. Geometry and +// texture keys inside a "left"/"right" block are ignored in a two-eye build, +// because both eyes share one set of polar maps and one copy of each texture. +static void applyVariant(JsonVariantConst o, EyeVariant &v) { + if (o.isNull()) + return; + JsonVariantConst x; + x = o["irisSpin"]; + if (x.is() || x.is()) + v.irisSpin = x.as(); + x = o["scleraSpin"]; + if (x.is() || x.is()) + v.scleraSpin = x.as(); + x = o["irisAngle"]; + if (x.is()) + v.irisStartAngle = 1023 - (x.as() & 1023); + else if (x.is()) + v.irisStartAngle = 1023 - ((int)(x.as() * 1024.0f) & 1023); + x = o["scleraAngle"]; + if (x.is()) + v.scleraStartAngle = 1023 - (x.as() & 1023); + else if (x.is()) + v.scleraStartAngle = 1023 - ((int)(x.as() * 1024.0f) & 1023); + x = o["irisMirror"]; + if (x.is() || x.is()) + v.irisMirror = x.as() ? 1023 : 0; + x = o["scleraMirror"]; + if (x.is() || x.is()) + v.scleraMirror = x.as() ? 1023 : 0; + x = o["eyelidMirror"]; + if (x.is() || x.is()) + v.eyelidMirror = x.as(); +} + +bool eyeSettingsLoad(const char *filename) { + if (!fsMounted) + return false; + File32 f = fatfs.open(filename, FILE_READ); + if (!f) { + DBG("No %s on drive; using built-in defaults\n", filename); + return false; + } + JsonDocument doc; + DeserializationError err = deserializeJson(doc, f); + f.close(); + if (err) { + Serial.printf("Config parse error (%s); using built-in defaults\n", + err.c_str()); + return false; + } + applyObject(doc.as()); +#if NUM_EYES == 1 + // A single eye may take anything from its side's block, including geometry. + applyObject(doc[EYE_SIDE].as()); +#endif + seedVariants(); +#if NUM_EYES > 1 + applyVariant(doc["right"].as(), eyeVariant[0]); + applyVariant(doc["left"].as(), eyeVariant[1]); +#endif + DBG("Loaded %s\n", filename); + return true; +} + +void eyeSettingsFinalize(void) { + // 0 means "fill whatever the display can give one eye"; setup() resolves it + // once the backend is up. Anything else is clamped to a sane range. + if (settings.displaySize != 0) { + if (settings.displaySize < 64) + settings.displaySize = 64; + if (settings.displaySize > 240) + settings.displaySize = 240; + settings.displaySize &= ~1; // Keep even; the renderer halves it + } + + if (settings.eyeRadius <= 0) + settings.eyeRadius = settings.displaySize / 2 + 5; + else + settings.eyeRadius = abs(settings.eyeRadius); + + // Auto values keep the stock demon proportions at ANY displaySize, so a + // config can change size alone and stay geometrically consistent: + // eyeRadius 240 -> 125 (displaySize/2 + 5) + // irisRadius 240 -> 110 (0.4583 * displaySize) + // slitPupilRadius 240 -> 100 (0.4167 * displaySize) + // A mismatch between these is what lets the iris wander out of frame, so + // leaving them at 0 / -1 is the safest way to resize the eye. + if (settings.irisRadius <= 0) + settings.irisRadius = (int)(0.4583f * (float)settings.displaySize + 0.5f); + else + settings.irisRadius = abs(settings.irisRadius); + // screen2map() takes sqrt(eyeRadius^2 - irisRadius^2); keep it real + if (settings.irisRadius >= settings.eyeRadius) + settings.irisRadius = settings.eyeRadius - 1; + + // 0 means a round pupil, so negative is the "auto" signal here. + if (settings.slitPupilRadius < 0) + settings.slitPupilRadius = + (int)(0.4167f * (float)settings.displaySize + 0.5f); + if (settings.slitPupilRadius > settings.irisRadius) + settings.slitPupilRadius = settings.irisRadius; + + // COVERAGE MUST BE LARGE ENOUGH FOR THE EYE TO LOOK AROUND. + // + // The gaze travels within a disc of radius + // (mapDiameter - displaySize * pi/2) * 0.75 + // and mapRadius is eyeRadius * pi * coverage. If eyeRadius is small relative + // to displaySize -- which happens when a config sets a new displaySize but + // leaves eyeRadius at a value scaled for the old one -- that radius goes to + // zero and then negative, and the eye drifts until the iris slides off the + // eyeball. Solving for the ratio the stock 240px demon eye uses (a gaze + // radius of about 0.30 * mapRadius) gives mapRadius ~= 0.98 * displaySize. + settings.coverage = settings.coverageRequested; + if (settings.displaySize == 0) + return; // Not resolved yet; nothing to check + const float wantMapRadius = 0.9818f * (float)settings.displaySize; + const float needCoverage = + wantMapRadius / ((float)settings.eyeRadius * (float)M_PI); + // Tolerance: the stock geometry lands within rounding distance of the + // requirement, and warning about a 0.00003 shortfall is just noise. + if (settings.coverage < needCoverage * 0.98f) { + DBG("coverage %.2f too low for displaySize %d with eyeRadius %d; " + "raising to %.2f\n", + settings.coverage, settings.displaySize, settings.eyeRadius, + needCoverage); + DBG(" (better fix: set eyeRadius near %d in config.eye)\n", + settings.displaySize / 2 + 5); + settings.coverage = needCoverage; + } + if (settings.coverage < 0.05f) + settings.coverage = 0.05f; + else if (settings.coverage > 1.0f) + settings.coverage = 1.0f; + + if (settings.pupilMin < 0.0f) + settings.pupilMin = 0.0f; + if (settings.pupilMax > 1.0f) + settings.pupilMax = 1.0f; + if (settings.pupilMin > settings.pupilMax) { + float t = settings.pupilMin; + settings.pupilMin = settings.pupilMax; + settings.pupilMax = t; + } + if (settings.trackFactor < 0.0f) + settings.trackFactor = 0.0f; + else if (settings.trackFactor > 1.0f) + settings.trackFactor = 1.0f; +} + +// =========================================================================== +// 3. STORAGE +// =========================================================================== +// +// Assets live on a FAT volume in the board's QSPI flash, read through +// Adafruit_SPIFlash + SdFat. By default this is the CircuitPython partition +// (see flash_config.h), so the drive is the familiar pre-formatted CIRCUITPY +// volume and M4SK-style eye folders drop straight in. +// + +static Adafruit_USBD_MSC usb_msc; +static volatile bool mscWritten = false; +static volatile uint32_t lastWriteMillis = 0; + +// These three run in USB interrupt context. Keep them to block I/O only. +static int32_t mscReadCb(uint32_t lba, void *buffer, uint32_t bufsize) { + return flash.readBlocks(lba, (uint8_t *)buffer, bufsize / 512) + ? (int32_t)bufsize + : -1; +} + +static int32_t mscWriteCb(uint32_t lba, uint8_t *buffer, uint32_t bufsize) { + mscWritten = true; + lastWriteMillis = millis(); + return flash.writeBlocks(lba, buffer, bufsize / 512) ? (int32_t)bufsize : -1; +} + +static void mscFlushCb(void) { + flash.syncBlocks(); + fatfs.cacheClear(); + lastWriteMillis = millis(); +} + +bool eyeStorageBegin(void) { + if (!flash.begin()) { + Serial.println("Flash chip init failed."); + fsMounted = false; + return false; + } + DBG("Flash JEDEC ID 0x%06lX, %lu bytes\n", (unsigned long)flash.getJEDECID(), + (unsigned long)flash.size()); + + if (!fatfs.begin(&flash)) { + Serial.println("No FAT filesystem found on the flash partition."); + Serial.println("Either load CircuitPython once to create CIRCUITPY, or"); + Serial.println("hold BOOTSEL at reset and let the host format the drive."); + fsMounted = false; + return false; + } + fsMounted = true; + return true; +} + +void eyeStorageEnd(void) { fsMounted = false; } + +bool eyeStorageDriveModeRequested(void) { return platformSafeModeRequested(); } + +void eyeStorageRunDriveMode(void) { + DBGLN("=== USB DRIVE MODE ==="); + DBGLN("DVI is intentionally off. Copy files, then eject."); + + if (!flash.begin()) { + Serial.println("Flash chip init failed; cannot export a drive."); + for (;;) + delay(1000); + } + fsMounted = fatfs.begin(&flash); + if (!fsMounted) + Serial.println("Volume not mountable -- format it from the host."); + + usb_msc.setID("Adafruit", "Eye Assets", "1.0"); + usb_msc.setCapacity(flash.size() / 512, 512); + usb_msc.setReadWriteCallback(mscReadCb, mscWriteCb, mscFlushCb); + usb_msc.setUnitReady(true); + usb_msc.begin(); + + DBGLN("Drive exported. Rebooting automatically once writes stop."); + +#ifdef LED_BUILTIN + pinMode(LED_BUILTIN, OUTPUT); +#endif + uint32_t lastBlink = 0; + bool ledState = false; + + for (;;) { + if (mscWritten && ((millis() - lastWriteMillis) > 2000)) { + DBGLN("Writes finished -- rebooting into eye mode."); + flash.syncBlocks(); + delay(250); + platformReboot(); + } + uint32_t now = millis(); + uint32_t period = mscWritten ? 120 : 600; // Fast blink after a write + if ((now - lastBlink) >= period) { + lastBlink = now; + ledState = !ledState; +#ifdef LED_BUILTIN + digitalWrite(LED_BUILTIN, ledState); +#endif + } + delay(5); + } +} + +// Shared by every display backend +void eyePanelReset(const int *rstPins, int count) { + bool any = false; + for (int i = 0; i < count; i++) { + if (rstPins[i] < 0) + continue; + bool dup = false; + for (int j = 0; j < i; j++) + if (rstPins[j] == rstPins[i]) + dup = true; + if (dup) { + DBG(" panel %d shares RST GPIO%d\n", i, rstPins[i]); + continue; + } + pinMode(rstPins[i], OUTPUT); + digitalWrite(rstPins[i], HIGH); + any = true; + } + if (!any) + return; + delay(10); + for (int i = 0; i < count; i++) + if (rstPins[i] >= 0) + digitalWrite(rstPins[i], LOW); + delay(20); + for (int i = 0; i < count; i++) + if (rstPins[i] >= 0) + digitalWrite(rstPins[i], HIGH); + delay(150); // Controllers want ~120 ms after reset before commands + DBGLN(" panels reset"); +} + +void displayPathTest(int size) { + const uint16_t c[2] = {0xF800, 0x001F}; + const int stride = displayColumnStride; + for (int e = 0; e < NUM_EYES; e++) { + displayEyeBegin(e); + for (int x = 0; x < size; x++) { + uint16_t *p = displayColumn(e, x); + if (!p) + break; + for (int y = 0; y < size; y++, p += stride) + *p = OUT16(c[e]); + displayColumnDone(e, x); + } + displayEyeEnd(e); + DBG(" path test: panel %d filled %s via the render path\n", e, + e ? "BLUE" : "RED"); + } + delay(2500); +} + +// =========================================================================== +// 4. TABLES +// =========================================================================== +// +// Adapted from tablegen.cpp. The math is unchanged; sizes come from settings. +// +// The round eyeball is faked with a 2D displacement map rather than real 3D +// rotation. Both tables cover ONE QUADRANT and are mirrored at render time. +// + +uint8_t *displace = NULL; +uint8_t *polarAngle = NULL; +int8_t *polarDist = NULL; +int mapRadius = 0; +int mapDiameter = 0; + +float screen2map(int in) { + return atan2f((float)in, + sqrtf((float)(settings.eyeRadius * settings.eyeRadius - + in * in))) / + (float)M_PI_2 * (float)mapRadius; +} + +float map2screen(int in) { + return sinf((float)in / (float)mapRadius) * (float)M_PI_2 * + (float)settings.eyeRadius; +} + +static bool calcDisplacement(void) { + const int half = settings.displaySize / 2; + displace = (uint8_t *)eyeMalloc(half * half); + if (!displace) + return false; + + const float eyeRadius2 = (float)(settings.eyeRadius * settings.eyeRadius); + uint8_t *ptr = displace; + + // First quadrant only, "+Y is up". Pixel centers at +0.5 by design; that + // makes mirroring numerically correct. + for (int y = 0; y < half; y++) { + float dy = (float)y + 0.5f; + dy *= dy; + for (int x = 0; x < half; x++) { + float dx = (float)x + 0.5f; + float d2 = dx * dx + dy; + if (d2 <= eyeRadius2) { + float d = sqrtf(d2); + float h = sqrtf(eyeRadius2 - d2); // Hemisphere height at d + float a = atan2f(d, h); // 0 to pi/2 from center + float pa = a / (float)M_PI_2 * (float)mapRadius; + dx /= d; + *ptr++ = (uint8_t)(dx * pa) - x; + } else { + *ptr++ = 255; // Outside the eye + } + } + } + return true; +} + +static bool calcMap(void) { + const int pixels = mapRadius * mapRadius; + + polarAngle = (uint8_t *)eyeMalloc(pixels * 2); // One alloc for both tables + if (!polarAngle) + return false; + polarDist = (int8_t *)&polarAngle[pixels]; + + const float mapRadius2 = (float)mapRadius * (float)mapRadius; + const float iRad = screen2map(settings.irisRadius); + const float irisRadius2 = iRad * iRad; + + uint8_t *anglePtr = polarAngle; + int8_t *distPtr = polarDist; + + for (int y = 0; y < mapRadius; y++) { + float dy = (float)y + 0.5f, dy2 = dy * dy; + for (int x = 0; x < mapRadius; x++) { + float dx = (float)x + 0.5f; + float d2 = dx * dx + dy2; + if (d2 > mapRadius2) { + *anglePtr++ = 0; + *distPtr++ = -128; + } else { + float angle = (float)M_PI_2 - atan2f(dy, dx); // Clockwise, 0 at top + angle *= 512.0f / (float)M_PI; // 0 to <256 in Q1 + *anglePtr++ = (uint8_t)angle; + float d = sqrtf(d2); + if (d2 > irisRadius2) { // Sclera: 0..127 + d = ((float)mapRadius - d) / ((float)mapRadius - iRad); + *distPtr++ = (int8_t)(d * 127.0f); + } else { // Iris: -1..-127 + d = (iRad - d) / iRad; + *distPtr++ = (int8_t)(d * -127.0f) - 1; + } + } + } + } + + if (settings.slitPupilRadius > 0) { + for (int y = 0; y < mapRadius; y++) { + float dy = (float)y + 0.5f, dy2 = dy * dy; + for (int x = 0; x < mapRadius; x++) { + float dx = (float)x + 0.5f; + float d2 = dx * dx + dy2; + if (d2 > irisRadius2) + continue; + float xp = (float)x + 0.5f; + for (int i = 126; i >= 0; i--) { + float ratio = (float)i / 128.0f; // 0.0 open .. just under 1.0 slit + // A point between top of iris and top of slit pupil, and another + // between right of iris and center; find the circle through both. + float y1 = iRad - (iRad - (float)settings.slitPupilRadius) * ratio; + float x2 = iRad * (1.0f - ratio); + float xc = (x2 * x2 - y1 * y1) / (2.0f * x2); + float rx = x2 - xc; + float px = xp - xc; + if ((px * px + dy2) <= (rx * rx)) { + polarDist[y * mapRadius + x] = (int8_t)(-1 - i); + break; + } + } + } + } + } + return true; +} + +void eyeTablesFree(void) { + if (polarAngle) { + free(polarAngle); + polarAngle = NULL; + polarDist = NULL; + } + if (displace) { + free(displace); + displace = NULL; + } +} + +bool eyeTablesInit(void) { + eyeTablesFree(); + mapRadius = + (int)((float)settings.eyeRadius * (float)M_PI * settings.coverage + 0.5f); + mapDiameter = mapRadius * 2; + if (mapRadius < 8) + return false; + if (!calcMap()) { + eyeTablesFree(); + return false; + } + if (!calcDisplacement()) { + eyeTablesFree(); + return false; + } + return true; +} + +// =========================================================================== +// 5. MEDIA +// =========================================================================== +// +// Everything is optional. A missing texture becomes a 1x1 buffer holding the +// solid color from settings, which the renderer samples correctly and which +// still produces a properly sized, dilating pupil. A missing eyelid leaves +// the sweep tables at their init values, which reads as "no eyelid". + +uint8_t *upperOpen = NULL, *upperClosed = NULL; +uint8_t *lowerOpen = NULL, *lowerClosed = NULL; + +const uint16_t *irisData = NULL, *scleraData = NULL; +static uint16_t s_irisW = 0, s_irisH = 0, s_scleraW = 0, s_scleraH = 0; +static uint16_t s_irisSolid = 0, s_scleraSolid = 0; // 1x1 fallback storage + +uint16_t irisWidth(void) { return s_irisW; } +uint16_t irisHeight(void) { return s_irisH; } +uint16_t scleraWidth(void) { return s_scleraW; } +uint16_t scleraHeight(void) { return s_scleraH; } + +// Adapter so the BMP loaders can read an SdFat File32. Note seekSet() rather +// than seek(), and read() returns a signed count (-1 on error). +/** @brief Adapts an SdFat File32 to the BmpReader interface. */ +class FileBmpReader : public BmpReader { + File32 f; ///< Open file, or a closed handle if the path did not exist + +public: + /** + * @brief Open a file on the mounted volume. + * @param path Absolute path on the asset filesystem. + */ + explicit FileBmpReader(const char *path) { + if (fsMounted) + f = fatfs.open(path, FILE_READ); + } + ~FileBmpReader() { + if (f) + f.close(); + } + /** + * @brief Did the file open? + * @return true if the file is open and readable. + */ + bool ok() const { return (bool)f; } + bool seek(uint32_t pos) override { return f && f.seekSet(pos); } + size_t read(void *buf, size_t len) override { + if (!f) + return 0; + int n = f.read(buf, len); + return (n < 0) ? 0 : (size_t)n; + } +}; + +static void loadOneEyelid(const char *path, uint8_t *openT, uint8_t *closedT, + int size, bool isUpper) { + const char *label = isUpper ? "upper" : "lower"; + (void)label; + if (path && path[0]) { + FileBmpReader r(path); + if (r.ok() && bmpLoadEyelid(r, openT, closedT, size, isUpper)) { + DBG(" %s eyelid: %s\n", label, path); + return; + } + DBG(" %s eyelid: %s unusable -- no eyelid\n", label, path); + } else { + DBG(" %s eyelid: none specified\n", label); + } + // Init values mean "lid fully out of the way" + memset(openT, isUpper ? (uint8_t)(size - 1) : 0, size); + memset(closedT, isUpper ? (uint8_t)(size - 1) : 0, size); +} + +static bool loadOneTexture(const char *path, const uint16_t **data, uint16_t *w, + uint16_t *h, uint32_t budget, uint16_t *solidStore, + uint16_t solidColor, const char *label) { + if (path && path[0] && budget > 512) { + FileBmpReader r(path); + uint16_t *loaded = NULL; + if (r.ok() && bmpLoadTexture(r, &loaded, w, h, budget)) { +#if DISPLAY_BIG_ENDIAN + // Swap once here so the render loop never has to, and so the bytes are + // already wire-ready for writePixels(bigEndian=true) or a DMA. + for (uint32_t i = 0; i < (uint32_t)(*w) * (*h); i++) + loaded[i] = __builtin_bswap16(loaded[i]); +#endif + *data = loaded; + DBG(" %s: %s -> %ux%u (%u bytes)\n", label, path, *w, *h, + (unsigned)(*w * *h * 2)); + return true; + } + DBG(" %s: %s unusable -- solid color\n", label, path); + } else if (path && path[0]) { + DBG(" %s: no RAM for %s -- solid color\n", label, path); + } else { + DBG(" %s: none specified -- solid color\n", label); + } + *solidStore = solidColor; // 1x1 texture, exactly as the original does + *data = solidStore; + *w = *h = 1; + return false; +} + +bool eyeMediaLoad(int size, uint32_t texBudget) { + uint8_t *block = (uint8_t *)eyeMalloc((size_t)size * 4); // All four tables + if (!block) + return false; + upperOpen = &block[0]; + upperClosed = &block[size]; + lowerOpen = &block[size * 2]; + lowerClosed = &block[size * 3]; + + DBGLN("Media:"); + loadOneEyelid(settings.upperFile, upperOpen, upperClosed, size, true); + loadOneEyelid(settings.lowerFile, lowerOpen, lowerClosed, size, false); + + uint32_t scleraBudget = texBudget / 8; + if (scleraBudget > 4096) + scleraBudget = 4096; + + loadOneTexture(settings.irisFile, &irisData, &s_irisW, &s_irisH, + texBudget - scleraBudget, &s_irisSolid, settings.irisColor, + "iris"); + loadOneTexture(settings.scleraFile, &scleraData, &s_scleraW, &s_scleraH, + scleraBudget, &s_scleraSolid, settings.scleraColor, "sclera"); + return true; +} diff --git a/Adafruit_Monster_Eyes/flash_config.h b/Adafruit_Monster_Eyes/flash_config.h new file mode 100644 index 0000000..ee7d499 --- /dev/null +++ b/Adafruit_Monster_Eyes/flash_config.h @@ -0,0 +1,66 @@ +/** + * @file flash_config.h + * @brief Which flash region holds the asset filesystem, per chip. + * + * Adapted from Adafruit's flash_config.h. Assets live on a FAT volume in the + * board's flash, read through Adafruit_SPIFlash + SdFat; which region that is + * depends on the chip. + */ + +#ifndef FLASH_CONFIG_H_ +#define FLASH_CONFIG_H_ ///< Include guard + +#include + +// --------------------------------------------------------------------------- +#if defined(ARDUINO_ARCH_RP2040) // Also RP2350 under arduino-pico +// --------------------------------------------------------------------------- +// The RP2 QSPI flash holds both the program and the filesystem, and the two +// schemes place the filesystem differently: +// +// Adafruit_FlashTransport_RP2040 the partition set by +// Tools > Flash Size (END of flash) +// Adafruit_FlashTransport_RP2040_CPY CircuitPython's layout +// (start 1 MB, size = total - 1 MB) +// +// CPY is the default: it gives the familiar pre-formatted CIRCUITPY drive and +// matches how M4SK eye folders are laid out. With it, set Tools > Flash Size +// to an "FS 0MB" option so the core does not also claim the end of flash and +// overlap. Keep the sketch under 1 MB so it cannot collide with the start. + +/** Use CircuitPython flash layout rather than the core FS */ +#define USE_CIRCUITPY_PARTITION 1 + +#if defined(USE_CIRCUITPY_PARTITION) +Adafruit_FlashTransport_RP2040_CPY flashTransport; ///< Asset flash +#else +Adafruit_FlashTransport_RP2040 flashTransport; ///< Asset flash +#endif + +// --------------------------------------------------------------------------- +#elif defined(ARDUINO_ARCH_ESP32) +// --------------------------------------------------------------------------- +// The ESP32 keeps its filesystem in a FAT partition of the same flash as the +// program. This transport locates it by parsing the partition table, so +// Tools > Partition Scheme MUST include a FATFS partition -- for example +// "Default 4MB with ffat" or "8M with spiffs" replaced by a ffat variant. +// Without one, flash.begin() fails and the eye falls back to built-in +// defaults (a solid-colour eye), which is the symptom to look for. + +Adafruit_FlashTransport_ESP32 flashTransport; ///< Asset flash + +// --------------------------------------------------------------------------- +#elif defined(EXTERNAL_FLASH_USE_QSPI) +// --------------------------------------------------------------------------- +Adafruit_FlashTransport_QSPI flashTransport; ///< Asset flash + +#elif defined(EXTERNAL_FLASH_USE_SPI) +Adafruit_FlashTransport_SPI + flashTransport(EXTERNAL_FLASH_USE_CS, + EXTERNAL_FLASH_USE_SPI); ///< Asset flash + +#else +#error "No flash transport for this board -- add a branch to flash_config.h" +#endif + +#endif // FLASH_CONFIG_H_ diff --git a/Adafruit_Monster_Eyes/platform.h b/Adafruit_Monster_Eyes/platform.h new file mode 100644 index 0000000..8fa0ed9 --- /dev/null +++ b/Adafruit_Monster_Eyes/platform.h @@ -0,0 +1,274 @@ +/** + * @file platform.h + * @brief Chip abstraction and per-board defaults. + * + * Everything in this project is plain C++ except a handful of calls no + * Arduino core agrees on. They live here so porting to a new chip means + * adding one block, not hunting through the render loop. + * + * The board profiles below supply defaults for anything @ref settings.h left + * open -- which panel is likely attached, and which DVI carrier to assume. + */ + +#pragma once +#include "settings.h" +#include + +// Put a hot function in RAM instead of running it from flash. On RP2 the +// render loop otherwise fetches instructions through the XIP cache, which now +// competes with a DMA channel streaming pixels out of SRAM. +// +// static void EYE_HOT_FN(renderEye)(int e) { ... } +// +#if defined(ARDUINO_ARCH_RP2040) +/** + * @brief Place a hot function in RAM rather than flash. + * @param name Function name to qualify. + */ +#define EYE_HOT_FN(name) __not_in_flash_func(name) +#else +// ESP32 has IRAM_ATTR, which would work here syntactically, but IRAM is +// scarce and renderEye is large -- enabling it can push a build over the +// IRAM limit. Left off; try `#define EYE_HOT_FN(name) IRAM_ATTR name` if +// profiling shows the render loop stalling on flash fetches. +/** + * @brief No-op on chips where running from flash is not a bottleneck. + * @param name Function name to qualify. + */ +#define EYE_HOT_FN(name) name +#endif + +// ========================================================================= +#if defined(ARDUINO_ARCH_RP2040) // Also defined for RP2350 by arduino-pico +// ========================================================================= + +#define PLATFORM_NAME "RP2" ///< Chip family name, for the startup banner + +// -1 means "use the BOOTSEL button", which needs no extra hardware but halts +// XIP briefly to sample the QSPI CS pin. Set to a GPIO to use a real button. +#ifndef SAFE_MODE_PIN +/** GPIO whose press requests drive mode; -1 uses BOOTSEL */ +#define SAFE_MODE_PIN -1 +#endif + +static inline uint32_t platformFreeHeap(void) { return rp2040.getFreeHeap(); } +static inline uint32_t platformLargestFreeBlock(void) { + return rp2040.getFreeHeap(); +} +// One flat SRAM, so nothing to steer. +static inline void *eyeMalloc(size_t n) { return malloc(n); } +static inline void platformReboot(void) { rp2040.reboot(); } +static inline uint32_t platformCpuHz(void) { return F_CPU; } + +static inline bool platformSafeModeRequested(void) { +#if SAFE_MODE_PIN >= 0 + pinMode(SAFE_MODE_PIN, INPUT_PULLUP); + delay(1); + return digitalRead(SAFE_MODE_PIN) == LOW; +#else + return BOOTSEL; +#endif +} + +// ========================================================================= +#elif defined(ARDUINO_ARCH_ESP32) +// ========================================================================= + +#define PLATFORM_NAME "ESP32" ///< Chip family name, for the startup banner +#include + +// Most ESP32 boards wire the BOOT button to GPIO0. +#ifndef SAFE_MODE_PIN +/** GPIO whose press requests drive mode; -1 uses BOOTSEL */ +#define SAFE_MODE_PIN 0 +#endif + +static inline uint32_t platformFreeHeap(void) { return ESP.getFreeHeap(); } + +// PSRAM IS A TRAP FOR THIS WORKLOAD. With PSRAM configured, the default malloc +// sends large blocks to external RAM -- and the polar maps and iris texture are +// exactly that size. The render loop then does a random external read per +// pixel, which costs far more than the arithmetic around it. +// +// So eye data is allocated MALLOC_CAP_INTERNAL, and the texture budget is +// measured against internal RAM only. If internal RAM runs out the texture +// loader simply decimates further, which costs sharpness rather than speed. +static inline void *eyeMalloc(size_t n) { + void *p = heap_caps_malloc(n, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + return p ? p : malloc(n); // Fall back rather than fail outright +} + +static inline uint32_t platformLargestFreeBlock(void) { + return heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | + MALLOC_CAP_8BIT); +} +static inline void platformReboot(void) { ESP.restart(); } +static inline uint32_t platformCpuHz(void) { + return getCpuFrequencyMhz() * 1000000UL; +} + +static inline bool platformSafeModeRequested(void) { + pinMode(SAFE_MODE_PIN, INPUT_PULLUP); + delay(1); + return digitalRead(SAFE_MODE_PIN) == LOW; +} + +// ========================================================================= +#else +// ========================================================================= +#error "Unsupported architecture -- add a block to platform.h" +#endif + +// ========================================================================= +// BOARD PROFILES +// ========================================================================= + +// ---- Adafruit Feather RP2040 DVI ---------------------------------------- +#if defined(ARDUINO_ADAFRUIT_FEATHER_RP2040_DVI) +/** Board profile that matched, for the startup banner */ +#define EYE_BOARD_NAME "Feather RP2040 DVI" +#ifndef EYE_PANEL_DEFAULT +#define EYE_PANEL_DEFAULT EYE_PANEL_DVI ///< Panel this board most likely has +#endif +#ifndef DVI_PIN_CONFIG +/** PicoDVI carrier board pin map */ +#define DVI_PIN_CONFIG adafruit_feather_dvi_cfg +#endif +#define EYE_BOARD_DEFAULT_EYES 1 ///< Eye count that suits this board + +// ---- Adafruit Feather RP2040 -------------------------------------------- +#elif defined(ARDUINO_ADAFRUIT_FEATHER_RP2040) +/** Board profile that matched, for the startup banner */ +#define EYE_BOARD_NAME "Feather RP2040" +#ifndef EYE_PANEL_DEFAULT +#define EYE_PANEL_DEFAULT EYE_PANEL_ST7789 ///< Panel this board most likely has +#endif +#define EYE_BOARD_DEFAULT_EYES 2 ///< Eye count that suits this board + +// ---- Adafruit Feather RP2350 --------------------------------------------- +#elif defined(ARDUINO_ADAFRUIT_FEATHER_RP2350) +/** Board profile that matched, for the startup banner */ +#define EYE_BOARD_NAME "Feather RP2350" +#ifndef EYE_PANEL_DEFAULT +/** Panel this board most likely has */ +#define EYE_PANEL_DEFAULT EYE_PANEL_GC9A01A +#endif +#define EYE_BOARD_DEFAULT_EYES 2 ///< Eye count that suits this board + +// ---- Adafruit Metro RP2350 ---------------------------------------------- +#elif defined(ARDUINO_ADAFRUIT_METRO_RP2350) +/** Board profile that matched, for the startup banner */ +#define EYE_BOARD_NAME "Metro RP2350" +#ifndef EYE_PANEL_DEFAULT +/** Panel this board most likely has */ +#define EYE_PANEL_DEFAULT EYE_PANEL_GC9A01A +#endif +#ifndef TFT1_CS +#define TFT1_CS 22 ///< Chip select for panel 1 +#endif +#define EYE_BOARD_DEFAULT_EYES 2 ///< Eye count that suits this board + +// ---- Adafruit Metro ESP32-S3 -------------------------------------------- +#elif defined(ARDUINO_ADAFRUIT_METRO_ESP32S3) +/** Board profile that matched, for the startup banner */ +#define EYE_BOARD_NAME "Metro ESP32-S3" +#ifndef EYE_PANEL_DEFAULT +#define EYE_PANEL_DEFAULT EYE_PANEL_ST7789 ///< Panel this board most likely has +#endif +#define EYE_BOARD_DEFAULT_EYES 2 ///< Eye count that suits this board + +// ---- RP2040 QT Py + EYESPI BFF ------------------------------------------ +#elif defined(ARDUINO_ADAFRUIT_QTPY_RP2040) || \ + defined(ARDUINO_ADAFRUIT_QTPY_ESP32S2) +/** Board profile that matched, for the startup banner */ +#define EYE_BOARD_NAME "QT Py + EYESPI BFF" +#ifndef EYE_PANEL_DEFAULT +/** Panel this board most likely has */ +#define EYE_PANEL_DEFAULT EYE_PANEL_GC9A01A +#endif +#define EYE_BOARD_DEFAULT_EYES 1 ///< Eye count that suits this board +#ifndef TFT_CS +#if defined(TX) +#define TFT_CS TX ///< Chip select for panel 0 +#elif defined(PIN_SERIAL1_TX) +#define TFT_CS PIN_SERIAL1_TX ///< Chip select for panel 0 +#else +#error "QT Py profile: cannot resolve the TX pad; set TFT_CS in settings.h" +#endif +#endif +#ifndef TFT_DC +#if defined(RX) +#define TFT_DC RX ///< Data/command pin, shared by both panels +#elif defined(PIN_SERIAL1_RX) +#define TFT_DC PIN_SERIAL1_RX ///< Data/command pin, shared by both panels +#else +#error "QT Py profile: cannot resolve the RX pad; set TFT_DC in settings.h" +#endif +#endif +#ifndef TFT_RST +#define TFT_RST -1 ///< Panel reset pin, shared; -1 if tied to board reset +#endif + +// ---- Anything else ------------------------------------------------------ +#else +/** Board profile that matched, for the startup banner */ +#define EYE_BOARD_NAME "generic" +#ifndef EYE_PANEL_DEFAULT +#define EYE_PANEL_DEFAULT EYE_PANEL_ST7789 ///< Panel this board most likely has +#endif +#define EYE_BOARD_DEFAULT_EYES 2 ///< Eye count that suits this board +#endif + +// Wiring shared by every board whose profile did not say otherwise. These +// work on the Feather and Metro form factors. +#ifndef TFT_SCK +#define TFT_SCK SCK ///< SPI clock pin +#endif +#ifndef TFT_MOSI +#define TFT_MOSI MOSI ///< SPI data-out pin +#endif +#ifndef TFT_DC +#define TFT_DC 9 ///< Data/command pin, shared by both panels +#endif +#ifndef TFT_RST +#define TFT_RST 10 ///< Panel reset pin, shared; -1 if tied to board reset +#endif +#ifndef TFT_CS +#define TFT_CS 11 ///< Chip select for panel 0 +#endif +#ifndef TFT1_CS +#define TFT1_CS 12 ///< Chip select for panel 1 +#endif +#ifndef TFT_BACKLIGHT +#define TFT_BACKLIGHT -1 ///< Backlight enable pin; -1 if not switchable +#endif + +// DVI carrier fallback +// The carrier board's pin map: +// adafruit_feather_dvi_cfg Feather RP2040 DVI +// adafruit_dvibell_cfg PiCowbell DVI +// pico_sock_cfg Pico DVI Sock +// pimoroni_demo_hdmi_cfg Pimoroni Pico DV +#ifndef DVI_PIN_CONFIG +#define DVI_PIN_CONFIG pico_sock_cfg ///< PicoDVI carrier board pin map +#endif + +// Both panels share everything but chip select +#ifndef TFT_SPI_PORT +#define TFT_SPI_PORT SPI ///< Arduino SPI object driving panel 0 +#endif +#ifndef TFT1_SPI_PORT +#define TFT1_SPI_PORT TFT_SPI_PORT ///< Arduino SPI object driving panel 1 +#endif +#ifndef TFT1_SCK +#define TFT1_SCK TFT_SCK ///< SPI clock pin for panel 1 +#endif +#ifndef TFT1_MOSI +#define TFT1_MOSI TFT_MOSI ///< SPI data-out pin for panel 1 +#endif +#ifndef TFT1_DC +#define TFT1_DC TFT_DC ///< Data/command pin for panel 1 +#endif +#ifndef TFT1_RST +#define TFT1_RST TFT_RST ///< Reset pin for panel 1 +#endif diff --git a/Adafruit_Monster_Eyes/settings.h b/Adafruit_Monster_Eyes/settings.h new file mode 100644 index 0000000..8ce8ae0 --- /dev/null +++ b/Adafruit_Monster_Eyes/settings.h @@ -0,0 +1,308 @@ +/** + * @file settings.h + * @brief User settings -- the only file most people need to edit. + * + * Everything here is wrapped in \#ifndef, which gives four layers, each + * beating the one below: + * -# a -D flag on the compiler command line (what CI uses) + * -# whatever you uncomment in this file + * -# the board profile in @ref platform.h + * -# the generic defaults at the bottom of @ref platform.h + * + * Runtime appearance -- colours, iris size, textures, blink behaviour -- lives + * in config.eye on the USB drive. The values here are the fallbacks used when + * the drive has no config. + */ + +#pragma once + +// ========================================================================= +// 1. PANEL +// ========================================================================= +// +// EYE_PANEL_AUTO take the board profile's default +// EYE_PANEL_ST7789 240x240 or 240x320 square/rectangular TFT +// EYE_PANEL_GC9A01A 240x240 round TFT +// EYE_PANEL_ILI9341 240x320 TFT (Adafruit backend only) +// EYE_PANEL_DVI HDMI/DVI output via PicoDVI (RP2 only) + +#define EYE_PANEL_AUTO 0 ///< Take the panel the board profile picks +#define EYE_PANEL_ST7789 1 ///< 240x240 or 240x320 square/rectangular TFT +#define EYE_PANEL_GC9A01A 2 ///< 240x240 round TFT +#define EYE_PANEL_ILI9341 3 ///< 240x320 TFT (Adafruit backend only) +#define EYE_PANEL_DVI 4 ///< HDMI/DVI output via PicoDVI (RP2 only) + +#ifndef EYE_PANEL +/** Which panel is attached; see the EYE_PANEL_* values */ +#define EYE_PANEL EYE_PANEL_AUTO +#endif + +// One eye or two. 0 means "whatever suits this board" -- 1 where the adapter +// has a single chip select (EYESPI BFF) or the framebuffer leaves no room +// (DVI), 2 otherwise. A board profile cannot simply #define NUM_EYES, because +// this file is read first; eye.h resolves the 0 after platform.h has run. +#ifndef NUM_EYES +#define NUM_EYES 0 ///< Eyes to render; 1, 2, or 0 to follow the board profile +#endif + +// ========================================================================= +// 2. WIRING -> TFT SPI +// ========================================================================= +// +// Both panels share SCK, MOSI, DC and RST. Only chip select is per panel. +// Uncomment a line to override just that pin + +#ifndef TFT_SCK +// #define TFT_SCK SCK +#endif +#ifndef TFT_MOSI +// #define TFT_MOSI MOSI +#endif +#ifndef TFT_DC +// #define TFT_DC 9 +#endif +#ifndef TFT_RST +// #define TFT_RST 10 // -1 if tied to the board's reset line +#endif +#ifndef TFT_CS +// #define TFT_CS 11 // Panel 0 +#endif +#ifndef TFT1_CS +// #define TFT1_CS 12 // Panel 1, only used when NUM_EYES is 2 +#endif +#ifndef TFT_BACKLIGHT +// #define TFT_BACKLIGHT -1 // -1 if not switchable +#endif + +// ========================================================================= +// 3. DVI WIRING (EYE_PANEL_DVI only) +// ========================================================================= +// +// The carrier board's pin map: +// adafruit_feather_dvi_cfg Feather RP2040 DVI +// adafruit_dvibell_cfg PiCowbell DVI +// pico_sock_cfg Pico DVI Sock +// pimoroni_demo_hdmi_cfg Pimoroni Pico DV + +// #define DVI_PIN_CONFIG adafruit_feather_dvi_cfg +#ifndef DVI_RESOLUTION +#define DVI_RESOLUTION DVI_RES_320x240p60 ///< PicoDVI video mode +#endif + +// ========================================================================= +// 4. DEBUG MODE +// ========================================================================= +// With EYE_DEBUG off, the sketch prints one line a second -- +// the frame rate -- plus anything that actually went wrong. Startup, +// panel self-tests, memory reports and the render/transfer +// breakdown are all silent, and the boot delay is skipped. +#ifndef EYE_DEBUG +/** 1 enables startup logging, self-tests and frame profiling */ +#define EYE_DEBUG 0 +#endif + +// The rest of the settings are advanced, likely won't need/want to be adjusted + +// Panel size in pixels. +#ifndef TFT_W +#define TFT_W 240 ///< Panel width in pixels +#endif +#ifndef TFT_H +#define TFT_H 240 ///< Panel height in pixels +#endif + +// SPI clock +#ifndef TFT_SPI_HZ +/** SPI clock for pixel data; panel init uses the driver default */ +#define TFT_SPI_HZ 40000000 +#endif + +// Orientation +#ifndef TFT_ROTATION +#define TFT_ROTATION 0 ///< Adafruit_GFX rotation, 0-3 (Adafruit backend only) +#endif +#ifndef ESP_LCD_INVERT +/** Invert panel colours; most ST7789 and GC9A01A need this */ +#define ESP_LCD_INVERT 1 +#endif +#ifndef ESP_LCD_SWAP_XY +#define ESP_LCD_SWAP_XY 0 ///< Exchange rows and columns (esp_lcd backend) +#endif +#ifndef ESP_LCD_MIRROR_X +/** Mirror horizontally; GC9A01A stock orientation wants 1 */ +#define ESP_LCD_MIRROR_X 0 +#endif +#ifndef ESP_LCD_MIRROR_Y +#define ESP_LCD_MIRROR_Y 0 ///< Mirror vertically (esp_lcd backend) +#endif +#ifndef EYE_FORCE_ADAFRUIT_BACKEND +/** 1 uses Adafruit_GFX even where esp_lcd would serve */ +#define EYE_FORCE_ADAFRUIT_BACKEND 0 +#endif + +// Delay before any hardware is touched. If the sketch faults later, the USB +// port still exists for this long after every reset, so the IDE can always +// reset the board for the next upload. Set to 0 once things are stable. +#ifndef STARTUP_GRACE_MS +#if EYE_DEBUG +/** Delay before touching hardware, so USB enumerates first */ +#define STARTUP_GRACE_MS 3000 +#else +/** Delay before touching hardware, so USB enumerates first */ +#define STARTUP_GRACE_MS 0 +#endif +#endif + +// Bring-up tests. SELFTEST fills panel 0 red and panel 1 blue using the +// driver; PATHTEST repeats it through the actual render path. A panel dark in +// both means wiring; dark only in the second means the transfer path. +#ifndef DISPLAY_SELFTEST +/** Run the driver-level red/blue panel test at boot */ +#define DISPLAY_SELFTEST EYE_DEBUG +#endif +#ifndef DISPLAY_PATHTEST +/** Repeat the panel test through the render path */ +#define DISPLAY_PATHTEST EYE_DEBUG +#endif + +// Report render vs transfer time once a second. +#ifndef PROFILE_FRAME +/** Report render versus transfer time once a second */ +#define PROFILE_FRAME EYE_DEBUG +#endif + +// Turn these off one at a time to bisect a startup hang. +#ifndef ENABLE_BOOTSEL_DRIVE +/** Allow BOOTSEL at reset to enter USB drive mode */ +#define ENABLE_BOOTSEL_DRIVE 1 +#endif +#ifndef ENABLE_STORAGE +/** Mount the asset filesystem; 0 uses built-in defaults */ +#define ENABLE_STORAGE 1 +#endif + +// ========================================================================= +// 5. MEMORY +// ========================================================================= + +// Heap kept clear of textures, for stack and driver buffers. +#ifndef HEAP_RESERVE +/** Bytes kept clear of textures for stack and driver buffers */ +#define HEAP_RESERVE 10000 +#endif + +// Smallest texture worth having. If the eye size requested does not leave +// this much over, setup() shrinks the eye rather than rendering it flat. +#ifndef MIN_TEXTURE_BUDGET +/** Below this the eye shrinks rather than render flat */ +#define MIN_TEXTURE_BUDGET 10000 +#endif + +// Columns batched into one address window. Each window costs a fixed command +// sequence regardless of size, so batching is close to free frame rate. Must +// divide the eye size; the backend picks the largest divisor at or below this. +// Costs stripe * eyeSize * 4 bytes. +#ifndef TFT_STRIPE_COLS +/** Columns batched into one address window (Adafruit backend) */ +#define TFT_STRIPE_COLS 16 +#endif +#ifndef ESP_LCD_STRIPE_COLS +/** Columns batched into one draw_bitmap (esp_lcd backend) */ +#define ESP_LCD_STRIPE_COLS 16 +#endif + +// RP2 only: batched SPI writes, and DMA so transfers overlap rendering. +#ifndef TFT_FAST_SPI +/** RP2 only: batch pixels straight into the SPI hardware */ +#define TFT_FAST_SPI 1 +#endif +#ifndef TFT_DMA +/** RP2 only: send columns by DMA so transfers overlap rendering */ +#define TFT_DMA 1 +#endif + +// ========================================================================= +// 6. FALLBACK EYE (used only when the drive has no config.eye) +// ========================================================================= + +#ifndef CONFIG_FILENAME +/** Path to the JSON eye configuration on the drive */ +#define CONFIG_FILENAME "/config.eye" +#endif +// Which per-eye block a single-eye build reads from a two-eye .eye file. +#ifndef EYE_SIDE +/** Which per-eye block a single-eye build reads, "left" or "right" */ +#define EYE_SIDE "left" +#endif + +// 0 means "fill the display". The three radii scale with it when left at +// their auto values, keeping the stock proportions at any size. +#ifndef DISPLAY_SIZE +#define DISPLAY_SIZE 0 ///< Fallback eye size in pixels; 0 fills the display +#endif +#ifndef EYE_RADIUS +#define EYE_RADIUS 0 ///< Fallback eyeball radius; 0 derives displaySize/2 + 5 +#endif +#ifndef IRIS_RADIUS +#define IRIS_RADIUS 0 ///< Fallback iris radius; 0 derives 0.4583 * displaySize +#endif +#ifndef SLIT_PUPIL_RADIUS +#define SLIT_PUPIL_RADIUS 0 ///< Fallback slit pupil; 0 round, -1 auto +#endif +#ifndef COVERAGE +/** Fraction of the eyeball the polar map spans; keep above 0.55 */ +#define COVERAGE 0.6f +#endif + +#ifndef PUPIL_COLOR +#define PUPIL_COLOR 0x0000 ///< Fallback pupil colour, native-endian RGB565 +#endif +#ifndef BACK_COLOR +#define BACK_COLOR 0x5000 ///< Fallback back-of-eye colour, native-endian RGB565 +#endif +#ifndef EYELID_COLOR +#define EYELID_COLOR 0x0000 ///< Fallback eyelid colour, native-endian RGB565 +#endif +#ifndef IRIS_COLOR +#define IRIS_COLOR 0x001F ///< Fallback iris colour when no texture loads +#endif +#ifndef SCLERA_COLOR +#define SCLERA_COLOR 0xFFFF ///< Fallback sclera colour when no texture loads +#endif + +#ifndef PUPIL_MIN +#define PUPIL_MIN 0.05f ///< Smallest pupil as a fraction of the iris +#endif +#ifndef PUPIL_MAX +#define PUPIL_MAX 0.25f ///< Largest pupil as a fraction of the iris +#endif +// Eyelid tracking: the upper lid follows the iris, so the eye rests partly +// closed rather than staring. On by default, matching upstream M4_Eyes. +// TRACK_FACTOR is 1.0 - squint; config.eye sets "squint" instead. +#ifndef TRACKING +/** Upper eyelid follows the iris, so the eye rests partly closed */ +#define TRACKING 1 +#endif +#ifndef TRACK_FACTOR +#define TRACK_FACTOR 0.5f ///< 1.0 minus squint; how far the lid drops at rest +#endif +#ifndef GAZE_MAX +/** Longest wait between major eye movements, microseconds */ +#define GAZE_MAX 3000000 +#endif +#ifndef IRIS_SPIN +/** Fallback iris rotation in RPM, positive is clockwise */ +#define IRIS_SPIN -18.0f +#endif +#ifndef IRIS_START_ANGLE +/** Fallback initial iris rotation, 0-1023 counter-clockwise */ +#define IRIS_START_ANGLE 512 +#endif +#ifndef EYELID_MIRROR +#define EYELID_MIRROR 1 ///< Mirror the eyelid shape horizontally +#endif +// Two eyes toe in slightly, in polar-map pixels. Ignored for one eye. +#ifndef EYE_FIXATE +/** Convergence of two eyes toward the face centre, map pixels */ +#define EYE_FIXATE 7 +#endif diff --git a/PicoDVI_Eyes/PicoDVI_Eyes.ino b/PicoDVI_Eyes/PicoDVI_Eyes.ino deleted file mode 100644 index 0e4acc6..0000000 --- a/PicoDVI_Eyes/PicoDVI_Eyes.ino +++ /dev/null @@ -1,503 +0,0 @@ -// M4_Eyes ported to PicoDVI (RP2040 / RP2350). -// -// Original: Phillip Burgess for Adafruit Industries, MIT license. -// This port keeps the eye math intact and replaces the SAMD51 SPI-DMA output -// path with a framebuffer. Everything that existed only to feed two ST7789s -// over DMA is gone: columnStruct, renderBuf, DmacDescriptor lists, DMAbuddy, -// dma_busy / column_ready, the DMA stall timeout, and the eyelidIndex trick -// (which required both bytes of the eyelid color to be identical). Two eyes -// now cost barely more RAM than one, because the polar maps, displacement -// table, textures and eyelid tables are all shared. This build renders ONE -// eye, centered. -// -// All assets live on the USB drive; nothing is compiled in. Hold BOOTSEL at -// reset to expose the drive and drop in a different eye. -// -// COORDINATE SYSTEM. The original rendered into a display set to rotation 3, -// which put the code in a frame where +Y is UP and +X is RIGHT, with the eye -// drawn column at a time. That frame is preserved here, so writing to the -// framebuffer is just a vertical flip: -// -// fb[(ORIGIN_Y + SIZE - 1 - y) * FB_WIDTH + ORIGIN_X + x] -// -// No rotation, no transpose. Confirmed against the eyelid tables, where the -// upper lid occupies high y and image row 0 maps to y = SIZE-1. -// -// TEARING. DVIGFX16 cannot double-buffer -- there is not enough RAM. Since -// rendering runs column by column across every scanline, fast saccades can -// shear. If that bothers you, DVIGFX8 has a real swap() synced to vsync; see -// the notes at the bottom of this file. - -#include -#include -#include "eye.h" - -// Live geometry, taken from `settings` once at startup so the render loop -// never dereferences the settings struct per pixel. -static int SIZE, HALF, ORIGIN_X, ORIGIN_Y; - -// Pin config comes from DVI_PIN_CONFIG in eye.h -- set it for your board. -DVIGFX16 display(DVI_RES_320x240p60, DVI_PIN_CONFIG); - -// EYE STATE --------------------------------------------------------------- - -#define NOBLINK 0 -#define ENBLINK 1 -#define DEBLINK 2 - -typedef struct { - float irisSpin; // RPM * -1024 (negative is clockwise to a viewer) - uint16_t irisStartAngle; // 0-1023 CCW - uint16_t irisAngle; - - uint8_t blinkState; - uint32_t blinkDuration; - uint32_t blinkStartTime; - float blinkFactor; - - float eyeX, eyeY; // Position in map space, saved per eye to avoid tearing - float pupilFactor; - float upperLidFactor, lowerLidFactor; -} eyeState; - -static eyeState eye[NUM_EYES]; - -// Shared animation state -static bool eyeInMotion = false; -static float eyeOldX, eyeOldY, eyeNewX, eyeNewY; -static uint32_t eyeMoveStartTime = 0; -static int32_t eyeMoveDuration = 0; -static uint32_t lastSaccadeStop = 0; -static int32_t saccadeInterval = 0; -static uint32_t timeOfLastBlink = 0; -static uint32_t timeToNextBlink = 0; -static float frameEyeX, frameEyeY; - -// Autonomous iris scaling via fractal subdivision (no light sensor here) -#define IRIS_LEVELS 7 -static float irisPrev[IRIS_LEVELS] = {0}; -static float irisNext[IRIS_LEVELS] = {0}; -static uint16_t irisFrame = 0; -static float irisValue = 0.5f; -static float irisMin, irisRange; - -static uint32_t frames = 0; -static uint32_t lastFrameReport = 0; - -// SETUP ------------------------------------------------------------------- - -void setup() { - Serial.begin(115200); - - // Drive mode is checked FIRST, before DVI touches core1 or the PIOs. - // See eye_storage.cpp for why the two modes cannot run at the same time. - if (eyeStorageDriveModeRequested()) { - eyeStorageRunDriveMode(); // Never returns; reboots on eject - } - - eyeSettingsDefaults(); - if (eyeStorageBegin()) { - eyeSettingsLoad(CONFIG_FILENAME); - } - eyeSettingsFinalize(); - - // Framebuffer first: it is the single largest allocation and must not have - // to fight fragmentation from anything else. - if (!display.begin()) { - // Framebuffer allocation failed. Almost always means the eye tables or - // something else claimed RAM first, or the resolution is too large. - pinMode(LED_BUILTIN, OUTPUT); - for (;;) digitalWrite(LED_BUILTIN, (millis() / 200) & 1); - } - display.fillScreen(0); - Serial.printf("Framebuffer up. Free heap: %u\n", rp2040.getFreeHeap()); - - // pupilMin/pupilMax in the file are the inverse of the irisMin/irisRange - // the renderer wants. - irisMin = 1.0f - settings.pupilMax; - irisRange = settings.pupilMax - settings.pupilMin; - - // Build the polar and displacement maps. If a config asks for an eye too - // large for the remaining heap, step the size down rather than failing -- - // the maps grow as mapRadius^2, so this converges quickly. - uint32_t t0 = millis(); - while (!eyeTablesInit()) { - if (settings.displaySize <= 96) { - Serial.println("Cannot allocate eye tables even at minimum size."); - for (;;) delay(1000); - } - settings.displaySize -= 16; - settings.eyeRadius = settings.displaySize / 2 + 5; - eyeSettingsFinalize(); - Serial.printf("Not enough RAM; retrying at displaySize %d\n", settings.displaySize); - } - Serial.printf("Tables built in %lu ms (size %d, mapRadius %d). Free heap: %u\n", - millis() - t0, settings.displaySize, mapRadius, rp2040.getFreeHeap()); - - // Whatever is left, minus a reserve, is the texture budget. - uint32_t freeHeap = rp2040.getFreeHeap(); - uint32_t texBudget = (freeHeap > HEAP_RESERVE) ? (freeHeap - HEAP_RESERVE) : 0; - if (!eyeMediaLoad(settings.displaySize, texBudget)) { - Serial.println("Eyelid table allocation failed."); - for (;;) delay(1000); - } - - // Nothing else reads the filesystem; let go of it so flash stays quiet. - eyeStorageEnd(); - - SIZE = settings.displaySize; - HALF = SIZE / 2; - ORIGIN_X = (FB_WIDTH - SIZE) / 2; - ORIGIN_Y = (FB_HEIGHT - SIZE) / 2; - Serial.printf("Running. Free heap: %u\n", rp2040.getFreeHeap()); - - for (uint8_t e = 0; e < NUM_EYES; e++) { - eye[e].irisSpin = -1024.0f * settings.irisSpin; - eye[e].irisStartAngle = settings.irisStartAngle; - eye[e].irisAngle = eye[e].irisStartAngle; - eye[e].blinkState = NOBLINK; - eye[e].blinkFactor = 0.0f; - eye[e].pupilFactor = 0.5f; - eye[e].upperLidFactor = 1.0f; - eye[e].lowerLidFactor = 1.0f; - eye[e].eyeX = eye[e].eyeY = (float)mapRadius; - } - - eyeOldX = eyeNewX = eyeOldY = eyeNewY = (float)mapRadius; - frameEyeX = frameEyeY = (float)mapRadius; - - randomSeed(micros()); -} - -// ONCE-PER-FRAME ANIMATION ------------------------------------------------ - -static void updateGaze(uint32_t t) { - int32_t dt = t - eyeMoveStartTime; - - if (eyeInMotion) { - if (dt >= eyeMoveDuration) { // Destination reached - eyeInMotion = false; - uint32_t limit = min((uint32_t)1000000, settings.gazeMax); - eyeMoveDuration = random(35000, limit); // Hold before next microsaccade - if (!saccadeInterval) { - lastSaccadeStop = t; - saccadeInterval = random(eyeMoveDuration, settings.gazeMax); - } - eyeMoveStartTime = t; - frameEyeX = eyeOldX = eyeNewX; - frameEyeY = eyeOldY = eyeNewY; - } else { // Interpolate, ease in/out - float e = (float)dt / (float)eyeMoveDuration; - e = 3.0f * e * e - 2.0f * e * e * e; - frameEyeX = eyeOldX + (eyeNewX - eyeOldX) * e; - frameEyeY = eyeOldY + (eyeNewY - eyeOldY) * e; - } - } else { - frameEyeX = eyeOldX; - frameEyeY = eyeOldY; - if (dt > eyeMoveDuration) { - if ((t - lastSaccadeStop) > (uint32_t)saccadeInterval) { - // Full saccade. r is how far the gaze can travel from center; it is - // what collapses if COVERAGE is set too low. - float r = ((float)mapDiameter - (float)SIZE * (float)M_PI_2) * 0.75f; - eyeNewX = random(-r, r); - float h = sqrtf(r * r - eyeNewX * eyeNewX); - eyeNewY = random(-h, h); - eyeMoveDuration = random(83000, 166000); - saccadeInterval = 0; - } else { - // Microsaccade, roughly 1/10 the radius. No clipping: a slight stray - // is corrected by the next full saccade. - float r = ((float)mapDiameter - (float)SIZE * (float)M_PI_2) * 0.07f; - float dx = random(-r, r); - eyeNewX = frameEyeX - mapRadius + dx; - float h = sqrtf(r * r - dx * dx); - eyeNewY = frameEyeY - mapRadius + random(-h, h); - eyeMoveDuration = random(7000, 25000); - } - eyeNewX += mapRadius; // Into map space - eyeNewY += mapRadius; - eyeMoveStartTime = t; - eyeInMotion = true; - } - } -} - -static void updateIris(void) { - float n, sum = 0.5f; - for (uint16_t i = 0; i < IRIS_LEVELS; i++) { - uint16_t iexp = 1 << (i + 1); - uint16_t imask = iexp - 1; - uint16_t ibits = irisFrame & imask; - if (ibits) { - float weight = (float)ibits / (float)iexp; - n = irisPrev[i] * (1.0f - weight) + irisNext[i] * weight; - } else { - n = irisNext[i]; - irisPrev[i] = irisNext[i]; - irisNext[i] = -0.5f + ((float)random(1000) / 999.0f); - } - iexp = 1 << (IRIS_LEVELS - i); - sum += n / (float)iexp; - } - irisValue = irisMin + (sum * irisRange); - if ((++irisFrame) >= (1 << IRIS_LEVELS)) irisFrame = 0; -} - -static void updateBlinks(uint32_t t) { - if ((t - timeOfLastBlink) >= timeToNextBlink) { - timeOfLastBlink = t; - uint32_t d = random(36000, 72000); - for (uint8_t e = 0; e < NUM_EYES; e++) { - if (eye[e].blinkState == NOBLINK) { - eye[e].blinkState = ENBLINK; - eye[e].blinkStartTime = t; - eye[e].blinkDuration = d; - } - } - timeToNextBlink = d * 3 + random(4000000); - } -} - -static void updateEye(uint8_t e, uint32_t t) { - eyeState &E = eye[e]; - - // In the two-eye build the eyes converged slightly toward the center of - // the face; a single centered eye has nothing to converge toward. - E.eyeX = frameEyeX; // No second eye to converge toward - E.eyeY = frameEyeY; - E.pupilFactor = irisValue; - - float uq, lq; - if (settings.tracking) { - int ix = (int)map2screen((float)mapRadius - E.eyeX) + HALF; - int iy = (int)map2screen((float)mapRadius - E.eyeY) + HALF; - iy += (int)(settings.irisRadius * settings.trackFactor); - if (settings.eyelidMirror) ix = SIZE - 1 - ix; - if (ix < 0) ix = 0; else if (ix > SIZE - 1) ix = SIZE - 1; - if (iy > upperOpen[ix]) uq = 1.0f; - else if (iy < upperClosed[ix]) uq = 0.0f; - else uq = (float)(iy - upperClosed[ix]) / - (float)(upperOpen[ix] - upperClosed[ix]); - lq = 1.0f - uq; - } else { - uq = lq = 1.0f; // Fully open when not blinking - } - E.upperLidFactor = (E.upperLidFactor * 0.6f) + (uq * 0.4f); - E.lowerLidFactor = (E.lowerLidFactor * 0.6f) + (lq * 0.4f); - - if (E.blinkState) { - if ((t - E.blinkStartTime) >= E.blinkDuration) { - if (++E.blinkState > DEBLINK) { - E.blinkState = NOBLINK; - E.blinkFactor = 0.0f; - } else { - E.blinkDuration *= 2; // Opening is half the speed of closing - E.blinkStartTime = t; - E.blinkFactor = 1.0f; - } - } else { - E.blinkFactor = (float)(t - E.blinkStartTime) / (float)E.blinkDuration; - if (E.blinkState == DEBLINK) E.blinkFactor = 1.0f - E.blinkFactor; - } - } - - // Cast through int32_t, NOT straight to uint16_t. Once irisSpin * mins goes - // negative, a direct float->unsigned conversion is undefined behavior, and - // ARM's __aeabi_f2uiz saturates it to 0 -- which pins the iris angle at zero - // and the iris stops spinning. Going via a signed int wraps correctly. - float mins = (float)millis() / 60000.0f; - E.irisAngle = (uint16_t)(int32_t)((float)E.irisStartAngle + - E.irisSpin * mins + 0.5f); -} - -// RENDER ------------------------------------------------------------------ - -static void renderEye(uint8_t e) { - eyeState &E = eye[e]; - uint16_t *fb = display.getBuffer(); - const int half = HALF; - - const int xPositionOverMap = (int)(E.eyeX - (float)half); - const int yPositionOverMap = (int)(E.eyeY - (float)half); - - const float upperLidFactor = (1.0f - E.blinkFactor) * E.upperLidFactor; - const float lowerLidFactor = (1.0f - E.blinkFactor) * E.lowerLidFactor; - const int irisH = irisHeight(), irisW = irisWidth(); - const int scleraH = scleraHeight(), scleraW = scleraWidth(); - const uint16_t *iris = irisData, *sclera = scleraData; - const int iPupilFactor = - (int)((float)irisH * 256.0f * (1.0f / E.pupilFactor)); - const uint16_t irisAngle = E.irisAngle; - const uint16_t pupilColor = settings.pupilColor; - const uint16_t backColor = settings.backColor; - const uint16_t eyelidColor = settings.eyelidColor; - const uint16_t irisMirror = settings.irisMirror; - const uint16_t scleraMirror = settings.scleraMirror; - const uint16_t scleraAngle = settings.scleraStartAngle; - const bool mirrorLids = settings.eyelidMirror; - - for (int x = 0; x < SIZE; x++) { - const int lidColumn = mirrorLids ? (SIZE - 1 - x) : x; - - // Destination pointer starts at the TOP of the column and walks up-screen - // (i.e. backward through memory) as y increases. - uint16_t *dst = &fb[(ORIGIN_Y + SIZE - 1) * FB_WIDTH + ORIGIN_X + x]; - - int y1 = (int)lowerClosed[lidColumn] + - (int)(0.5f + lowerLidFactor * (float)((int)lowerOpen[lidColumn] - - (int)lowerClosed[lidColumn])); - int y2 = (int)upperClosed[lidColumn] + - (int)(0.5f + upperLidFactor * (float)((int)upperOpen[lidColumn] - - (int)upperClosed[lidColumn])); - if (y1 > SIZE - 1) y1 = SIZE - 1; else if (y1 < 0) y1 = 0; - if (y2 > SIZE - 1) y2 = SIZE - 1; else if (y2 < 0) y2 = 0; - - if (y1 >= y2) { - // Lid closed far enough that no eye pixels show in this column - for (int y = 0; y < SIZE; y++, dst -= FB_WIDTH) *dst = eyelidColor; - continue; - } - - // Lower eyelid - int y = 0; - for (; y < y1; y++, dst -= FB_WIDTH) *dst = eyelidColor; - - // Displacement lookup setup for this column. Only one quadrant of the - // table exists; sign and axis swapping cover the rest. - const uint8_t *displaceX, *displaceY; - int8_t xmul; - if (x < half) { - displaceX = &displace[(half - 1) - x]; - displaceY = &displace[((half - 1) - x) * half]; - xmul = -1; - } else { - displaceX = &displace[x - half]; - displaceY = &displace[(x - half) * half]; - xmul = 1; - } - - const int xx = xPositionOverMap + x; - - for (; y <= y2; y++, dst -= FB_WIDTH) { - const int yy = yPositionOverMap + y; - int doff, dx, dy; - - if (y < half) { - doff = (half - 1) - y; - dy = -(int)displaceY[doff]; - } else { - doff = y - half; - dy = (int)displaceY[doff]; - } - dx = displaceX[doff * half]; - - if (dx >= 255) { // Outside the eyeball - *dst = eyelidColor; - continue; - } - dx *= xmul; - int mx = xx + dx; - int my = yy + dy; - - if ((mx < 0) || (mx >= mapDiameter) || (my < 0) || (my >= mapDiameter)) { - *dst = backColor; // Off the map - continue; - } - - int angle, dist, moff; - if (my >= mapRadius) { - if (mx >= mapRadius) { // Quadrant 1: direct - mx -= mapRadius; - my -= mapRadius; - moff = my * mapRadius + mx; - angle = polarAngle[moff]; - dist = polarDist[moff]; - } else { // Quadrant 2: rotate 90, mirror X - mx = mapRadius - 1 - mx; - my -= mapRadius; - angle = polarAngle[mx * mapRadius + my] + 768; - dist = polarDist[my * mapRadius + mx]; - } - } else { - if (mx < mapRadius) { // Quadrant 3: rotate 180 - mx = mapRadius - 1 - mx; - my = mapRadius - 1 - my; - moff = my * mapRadius + mx; - angle = polarAngle[moff] + 512; - dist = polarDist[moff]; - } else { // Quadrant 4: rotate 270, mirror Y - mx -= mapRadius; - my = mapRadius - 1 - my; - angle = polarAngle[mx * mapRadius + my] + 256; - dist = polarDist[my * mapRadius + mx]; - } - } - - if (dist >= 0) { // Sclera - int a = ((angle + scleraAngle) & 1023) ^ scleraMirror; - int tx = a * scleraW / 1024; - int ty = dist * scleraH / 128; - *dst = sclera[ty * scleraW + tx]; - } else if (dist > -128) { // Iris or pupil - int ty = dist * iPupilFactor / -32768; - if (ty >= irisH) { - *dst = pupilColor; - } else { - int a = ((angle + irisAngle) & 1023) ^ irisMirror; - int tx = a * irisW / 1024; - *dst = iris[ty * irisW + tx]; - } - } else { - *dst = backColor; // Back of eye - } - } - - // Upper eyelid - for (; y < SIZE; y++, dst -= FB_WIDTH) *dst = eyelidColor; - } -} - -// LOOP -------------------------------------------------------------------- - -void loop() { - uint32_t t = micros(); - - updateGaze(t); - updateBlinks(t); - updateIris(); - - for (uint8_t e = 0; e < NUM_EYES; e++) { - updateEye(e, t); - renderEye(e); - } - - frames++; - if ((t - lastFrameReport) >= 1000000) { - Serial.printf("%lu fps\n", frames); - frames = 0; - lastFrameReport = t; - } -} - -// NOTES ------------------------------------------------------------------- -// -// Moving to DVIGFX8 (256-color, 76,800 byte framebuffer, real vsync-synced -// swap()) frees ~77 KB and eliminates tearing. Changes needed: -// - declare DVIGFX8 display(DVI_RES_320x240p60, true, cfg) for double buffer -// - getBuffer() returns uint8_t*; textures become palette indices -// - bake_assets.py must quantize iris + sclera to a shared 256-color palette -// and emit uint8_t arrays plus the palette itself -// - call display.setColor() for each palette entry in setup(), then -// display.swap(false, true) once so both buffers share the palette -// - call display.swap() at the end of loop() -// The renderer gets slightly faster too, since it writes bytes. -// -// Eye size is now runtime-driven. A config.eye can set "displaySize", and if -// the request does not fit the heap, setup() steps it down 16 at a time until -// it does. The size/RAM table in eye_config.h shows where the limits fall. -// -// On RP2350 the whole question is moot: 520 KB is enough for a 16-bit -// double-buffered 320x240 plus a full-size 240x240 eye and in-RAM textures. -// Adafruit_DVI_HSTX drives DVI from the HSTX peripheral there, freeing a PIO -// and a good deal of core1 time versus PicoDVI's TMDS encoding. diff --git a/PicoDVI_Eyes/config.eye b/PicoDVI_Eyes/config.eye deleted file mode 100644 index 78c898e..0000000 --- a/PicoDVI_Eyes/config.eye +++ /dev/null @@ -1,35 +0,0 @@ -{ - // Single-eye PicoDVI build. Values not listed here keep the defaults - // compiled into eye_config.h. - // - // Sizes are in SCREEN PIXELS and scale with displaySize. The original - // 240px demon eye used eyeRadius 125 / irisRadius 110 / slitPupilRadius 100; - // these are those values scaled by 176/240. - - "displaySize" : 176, - "eyeRadius" : 93, - "irisRadius" : 80, - "slitPupilRadius" : 71, - - "eyelidIndex" : "0x00", - "pupilColor" : [ 0, 0, 0 ], - "pupilMin" : 0.05, - "pupilMax" : 0.25, - "backColor" : [ 80, 0, 0 ], - - "irisTexture" : "/demon/iris.bmp", - "scleraTexture" : "/demon/sclera.bmp", - "upperEyelid" : "/demon/upper.bmp", - "lowerEyelid" : "/demon/lower.bmp", - - "tracking" : false, - - // Per-eye overrides. This build reads the block named by EYE_SIDE in - // eye_config.h, which defaults to "left". - "left" : { - "irisSpin" : -18 - }, - "right" : { - "irisSpin" : 18 - } -} diff --git a/PicoDVI_Eyes/eye.h b/PicoDVI_Eyes/eye.h deleted file mode 100644 index 7b0c404..0000000 --- a/PicoDVI_Eyes/eye.h +++ /dev/null @@ -1,162 +0,0 @@ -// M4_Eyes on PicoDVI -- single header for configuration, types and interfaces. -// -// Everything the eye needs at runtime comes from a FAT volume in the RP2040's -// QSPI flash, read with Adafruit_SPIFlash + SdFat. By default that is the -// CircuitPython partition, so the drive is the usual CIRCUITPY volume and -// M4SK-style eye folders drop straight in. See flash_config.h to change it. -// -// There are no baked-in assets: if a file is missing or unreadable the eye -// degrades the way the original M4_Eyes did -- a missing texture becomes a -// solid color, a missing eyelid becomes no eyelid -- so the sketch always -// renders something. - -#pragma once -#include -#include - -// =========================================================================== -// CONFIGURATION -- defaults only; config.eye on the drive overrides these -// =========================================================================== - -#define FB_WIDTH 320 -#define FB_HEIGHT 240 -#define NUM_EYES 1 // Single centered eye - -// Change to match your carrier: -// adafruit_feather_dvi_cfg Feather RP2040 DVI -// adafruit_dvibell_cfg PiCowbell DVI -// pico_sock_cfg Pico DVI Sock -// pimoroni_demo_hdmi_cfg Pimoroni Pico DV -#define DVI_PIN_CONFIG adafruit_feather_dvi_cfg - -// Path to the config file on the drive. Assets it references are resolved -// relative to the volume root, e.g. "/demon/iris.bmp". -#define CONFIG_FILENAME "/config.eye" - -// Which per-eye override block to read from a two-eye .eye file. -// "left" reproduces the eye that appeared on the right of the original pair. -#define EYE_SIDE "left" - -// RAM BUDGET (RP2040 has 264 KB). The 16-bit framebuffer is a fixed 153,600 -// bytes and the polar maps grow as mapRadius^2, so displaySize is the knob: -// -// size maps+displace +framebuffer left for textures -// 144 47234 200834 ~41 KB -// 160 57600 211200 ~31 KB -// 176 68994 222594 ~20 KB -// 192 81416 235016 ~7 KB -// -// If a config asks for more than fits, setup() steps displaySize down by 16 -// until it does. Textures are decimated to fit whatever remains. -#define DISPLAY_SIZE 176 - -// Heap kept clear of textures for stack, TinyUSB and libdvi. Raise if you see -// allocation failures; lower if textures come out smaller than you'd like. -// Serial prints the real free-heap numbers during startup. -#define HEAP_RESERVE 6000 - -#define EYE_RADIUS 93 // 0 = derive as displaySize/2 + 5 -#define IRIS_RADIUS 80 -#define SLIT_PUPIL_RADIUS 71 // 0 = round pupil; smaller = narrower slit - -// mapRadius = eyeRadius * pi * coverage. Don't go far below 0.55: the saccade -// radius is (mapDiameter - displaySize*pi/2) * 0.75, which collapses to zero -// as coverage shrinks, freezing the gaze. -#define COVERAGE 0.6f - -// Native-endian RGB565. The big-endian convention the SAMD51 SPI-DMA path -// needed is gone, because GFXcanvas16 stores native uint16_t. -#define PUPIL_COLOR 0x0000 -#define BACK_COLOR 0x5000 // (80, 0, 0) -#define EYELID_COLOR 0x0000 -#define IRIS_COLOR 0x001F // Used when no iris texture is present -#define SCLERA_COLOR 0xFFFF // Used when no sclera texture is present - -#define PUPIL_MIN 0.05f -#define PUPIL_MAX 0.25f -#define TRACKING 0 -#define TRACK_FACTOR 0.5f -#define GAZE_MAX 3000000 // Max microseconds between major eye movements - -#define IRIS_SPIN -18.0f // RPM -#define IRIS_START_ANGLE 512 // 0-1023 CCW -#define EYELID_MIRROR 1 - -// =========================================================================== -// SETTINGS -// =========================================================================== - -#define EYE_PATH_MAX 64 - -struct EyeSettings { - int displaySize, eyeRadius, irisRadius, slitPupilRadius; - float coverage; - uint16_t pupilColor, backColor, eyelidColor, irisColor, scleraColor; - float pupilMin, pupilMax; - bool tracking; - float trackFactor; - uint32_t gazeMax; - float irisSpin, scleraSpin; // RPM, positive = clockwise to viewer - uint16_t irisStartAngle, scleraStartAngle; - uint16_t irisMirror, scleraMirror; // 0 or 1023 - bool eyelidMirror; - char irisFile[EYE_PATH_MAX], scleraFile[EYE_PATH_MAX]; - char upperFile[EYE_PATH_MAX], lowerFile[EYE_PATH_MAX]; -}; - -extern EyeSettings settings; - -void eyeSettingsDefaults(void); -bool eyeSettingsLoad(const char *filename); -void eyeSettingsFinalize(void); - -// =========================================================================== -// STORAGE -// =========================================================================== - -bool eyeStorageBegin(void); -void eyeStorageEnd(void); -bool eyeStorageDriveModeRequested(void); -void eyeStorageRunDriveMode(void); // Never returns; reboots on eject - -// =========================================================================== -// TABLES -// =========================================================================== - -extern uint8_t *displace; // (size/2)^2, 255 = outside eyeball -extern uint8_t *polarAngle; // mapRadius^2 -extern int8_t *polarDist; // mapRadius^2, >=0 sclera, <0 iris, -128 = off -extern int mapRadius, mapDiameter; - -bool eyeTablesInit(void); -void eyeTablesFree(void); -float screen2map(int in); -float map2screen(int in); - -// =========================================================================== -// MEDIA -// =========================================================================== - -extern uint8_t *upperOpen, *upperClosed, *lowerOpen, *lowerClosed; -extern const uint16_t *irisData, *scleraData; - -uint16_t irisWidth(void), irisHeight(void); -uint16_t scleraWidth(void), scleraHeight(void); - -bool eyeMediaLoad(int size, uint32_t texBudget); - -// =========================================================================== -// BMP -// =========================================================================== - -class BmpReader { -public: - virtual ~BmpReader() {} - virtual bool seek(uint32_t pos) = 0; - virtual size_t read(void *buf, size_t len) = 0; -}; - -bool bmpLoadEyelid(BmpReader &r, uint8_t *openTable, uint8_t *closedTable, - int size, bool isUpper); -bool bmpLoadTexture(BmpReader &r, uint16_t **data, uint16_t *width, - uint16_t *height, uint32_t maxBytes); diff --git a/PicoDVI_Eyes/eye_support.cpp b/PicoDVI_Eyes/eye_support.cpp deleted file mode 100644 index 68e02a4..0000000 --- a/PicoDVI_Eyes/eye_support.cpp +++ /dev/null @@ -1,754 +0,0 @@ -// M4_Eyes on PicoDVI -- support code. -// -// Sections, in order: -// 1. BMP loading (streaming, 1-bit eyelids and 24-bit textures) -// 2. Settings (config.eye JSON) -// 3. Storage (FatFS mount and USB drive mode) -// 4. Tables (polar and displacement maps) -// 5. Media (ties files to the renderer, with solid-color fallback) - -// Requires ArduinoJson 7.x (JsonDocument). On 6.x use StaticJsonDocument<2048>. -#define ARDUINOJSON_ENABLE_COMMENTS 1 -#include -#include -#include -#include "SdFat_Adafruit_Fork.h" -#include -#include -#include "flash_config.h" -#include -#include -#include -#include "eye.h" - -// =========================================================================== -// 1. BMP LOADING -// =========================================================================== -// -// Replaces Adafruit_ImageReader (and its SdFat / Adafruit_SPIFlash -// dependencies) with the only two cases the eye needs. Both loaders STREAM -- -// nothing allocates a full-size intermediate copy, which is what forced the -// "booster seat" fragmentation workaround in the original M4_Eyes. The texture -// loader also decimates on the fly to fit the RAM budget it is handed, so an -// oversized BMP loses resolution instead of failing. -// -// Output is native-endian RGB565 to match GFXcanvas16. - -// Angular resolution past 512 is wasted: the renderer indexes as -// (angle * width / 1024) with angle 0-1023, and distance is 0-127. -#define TEX_MAX_W 512 -#define TEX_MAX_H 128 - -struct BmpInfo { - int32_t width, height; // height always positive; see topDown - uint16_t bpp; - uint32_t dataOffset, rowSize; - bool topDown; - uint8_t whiteIndex; // 1-bit only: the lighter palette entry -}; - -static uint16_t rd16(const uint8_t *p) { return p[0] | (p[1] << 8); } -static uint32_t rd32(const uint8_t *p) { - return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | - ((uint32_t)p[3] << 24); -} - -static bool bmpReadHeader(BmpReader &r, BmpInfo &info) { - uint8_t hdr[54]; - if (!r.seek(0)) return false; - if (r.read(hdr, sizeof(hdr)) != sizeof(hdr)) return false; - if ((hdr[0] != 'B') || (hdr[1] != 'M')) return false; - - info.dataOffset = rd32(&hdr[10]); - uint32_t dibSize = rd32(&hdr[14]); - int32_t w = (int32_t)rd32(&hdr[18]); - int32_t h = (int32_t)rd32(&hdr[22]); - info.bpp = rd16(&hdr[28]); - uint32_t compression = rd32(&hdr[30]); - - if (dibSize < 40) return false; // BITMAPINFOHEADER+ - if (compression != 0) return false; // BI_RGB only - if ((info.bpp != 1) && (info.bpp != 24)) return false; - if (w <= 0) return false; - - info.topDown = (h < 0); // Negative height means top-down rows - info.height = info.topDown ? -h : h; - info.width = w; - if (info.height <= 0) return false; - - info.rowSize = (((uint32_t)w * info.bpp + 31) / 32) * 4; // 4-byte padded - - info.whiteIndex = 1; - if (info.bpp == 1) { - uint8_t pal[8]; // 2 entries, each B,G,R,reserved - if (!r.seek(14 + dibSize)) return false; - if (r.read(pal, sizeof(pal)) != sizeof(pal)) return false; - int lum0 = pal[0] + pal[1] + pal[2]; - int lum1 = pal[4] + pal[5] + pal[6]; - info.whiteIndex = (lum1 > lum0) ? 1 : 0; - } - return true; -} - -// Mirrors loadEyelid() in the original file.cpp: per column, find the topmost -// and bottommost lit pixel, then flip into render space where +Y is up. -// Unlike the original, which centered and CLIPPED against a fixed 240px -// screen, this scales proportionally so any source size fits any eye size. -bool bmpLoadEyelid(BmpReader &r, uint8_t *openTable, uint8_t *closedTable, - int size, bool isUpper) { - BmpInfo info; - if (!bmpReadHeader(r, info)) return false; - if (info.bpp != 1) return false; - if (info.height < 2) return false; - - const uint8_t init = isUpper ? (uint8_t)(size - 1) : 0; - memset(openTable, init, size); - memset(closedTable, init, size); - - uint16_t *minRow = (uint16_t *)malloc((size_t)size * 2 * sizeof(uint16_t)); - if (!minRow) return false; - uint16_t *maxRow = &minRow[size]; - for (int i = 0; i < size; i++) { minRow[i] = 0xFFFF; maxRow[i] = 0; } - - uint8_t *row = (uint8_t *)malloc(info.rowSize); - if (!row) { free(minRow); return false; } - - bool ok = true; - for (int32_t fileRow = 0; fileRow < info.height; fileRow++) { - if (!r.seek(info.dataOffset + (uint32_t)fileRow * info.rowSize) || - (r.read(row, info.rowSize) != info.rowSize)) { ok = false; break; } - - // Bottom-up is the BMP default: file row 0 is the image's last row - int32_t imageRow = info.topDown ? fileRow : (info.height - 1 - fileRow); - - for (int32_t sx = 0; sx < info.width; sx++) { - uint8_t bit = (row[sx >> 3] >> (7 - (sx & 7))) & 1; - if (bit != info.whiteIndex) continue; - int dx = (int)((int64_t)sx * size / info.width); - if (dx < 0) dx = 0; else if (dx >= size) dx = size - 1; - if ((uint16_t)imageRow < minRow[dx]) minRow[dx] = (uint16_t)imageRow; - if ((uint16_t)imageRow > maxRow[dx]) maxRow[dx] = (uint16_t)imageRow; - } - } - free(row); - - if (ok) { - for (int dx = 0; dx < size; dx++) { - if (minRow[dx] == 0xFFFF) continue; // No data; keep the init value - int my = (int)((int64_t)minRow[dx] * (size - 1) / (info.height - 1)); - int My = (int)((int64_t)maxRow[dx] * (size - 1) / (info.height - 1)); - if (my < 0) my = 0; else if (my > size - 1) my = size - 1; - if (My < 0) My = 0; else if (My > size - 1) My = size - 1; - if (isUpper) { - openTable[dx] = (uint8_t)(size - 1 - my); - closedTable[dx] = (uint8_t)(size - 1 - My); - } else { - closedTable[dx] = (uint8_t)(size - 1 - my); - openTable[dx] = (uint8_t)(size - 1 - My); - } - } - } - free(minRow); - return ok; -} - -bool bmpLoadTexture(BmpReader &r, uint16_t **data, uint16_t *width, - uint16_t *height, uint32_t maxBytes) { - BmpInfo info; - if (!bmpReadHeader(r, info)) return false; - if (info.bpp != 24) return false; - - // Start at the source size capped to what the renderer can address, then - // shrink the longer dimension until it fits the budget. - int dw = (int)info.width < TEX_MAX_W ? (int)info.width : TEX_MAX_W; - int dh = (int)info.height < TEX_MAX_H ? (int)info.height : TEX_MAX_H; - while (((uint32_t)dw * dh * 2 > maxBytes) && ((dw > 8) || (dh > 4))) { - if ((dw * (int)info.height) > (dh * (int)info.width)) { - if (dw > 8) dw--; else dh--; - } else { - if (dh > 4) dh--; else dw--; - } - } - if ((uint32_t)dw * dh * 2 > maxBytes) return false; - - uint16_t *dst = (uint16_t *)malloc((size_t)dw * dh * 2); - if (!dst) return false; - uint8_t *row = (uint8_t *)malloc(info.rowSize); - if (!row) { free(dst); return false; } - - bool ok = true; - for (int dy = 0; dy < dh; dy++) { - int32_t imageRow = (int32_t)((int64_t)dy * info.height / dh); - int32_t fileRow = info.topDown ? imageRow : (info.height - 1 - imageRow); - if (!r.seek(info.dataOffset + (uint32_t)fileRow * info.rowSize) || - (r.read(row, info.rowSize) != info.rowSize)) { ok = false; break; } - - uint16_t *out = &dst[(size_t)dy * dw]; - for (int dx = 0; dx < dw; dx++) { - int32_t sx = (int32_t)((int64_t)dx * info.width / dw); - const uint8_t *p = &row[(size_t)sx * 3]; // Stored B, G, R - *out++ = (uint16_t)(((p[2] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[0] >> 3)); - } - } - free(row); - if (!ok) { free(dst); return false; } - - *data = dst; *width = (uint16_t)dw; *height = (uint16_t)dh; - return true; -} - -// =========================================================================== -// 2. SETTINGS -// =========================================================================== - -// The QSPI flash chip and the FAT volume living on it. Both are shared by the -// settings loader, the media loader and drive mode. -Adafruit_SPIFlash flash(&flashTransport); -FatVolume fatfs; -static bool fsMounted = false; - -EyeSettings settings; - -void eyeSettingsDefaults(void) { - memset(&settings, 0, sizeof(settings)); - settings.displaySize = DISPLAY_SIZE; - settings.eyeRadius = EYE_RADIUS; - settings.irisRadius = IRIS_RADIUS; - settings.slitPupilRadius = SLIT_PUPIL_RADIUS; - settings.coverage = COVERAGE; - settings.pupilColor = PUPIL_COLOR; - settings.backColor = BACK_COLOR; - settings.eyelidColor = EYELID_COLOR; - settings.irisColor = IRIS_COLOR; - settings.scleraColor = SCLERA_COLOR; - settings.pupilMin = PUPIL_MIN; - settings.pupilMax = PUPIL_MAX; - settings.tracking = TRACKING; - settings.trackFactor = TRACK_FACTOR; - settings.gazeMax = GAZE_MAX; - settings.irisSpin = IRIS_SPIN; - settings.scleraSpin = 0.0f; - settings.irisStartAngle = IRIS_START_ANGLE; - settings.scleraStartAngle = IRIS_START_ANGLE; - settings.eyelidMirror = EYELID_MIRROR; -} - -// "Do What I Mean" decoder from the original file.cpp. Accepts 42, "0x2A", -// "0xF800", [255,0,0], ["0xFF",0,0], [1.0,0.0,0.0]. Unlike the original this -// returns NATIVE-endian RGB565. -static int32_t dwim(JsonVariantConst v, int32_t def = 0) { - if (v.is()) { - return v.as(); - } else if (v.is()) { - return (int32_t)(v.as() + 0.5f); - } else if (v.is()) { - return (int32_t)strtol(v.as(), NULL, 0); - } else if (v.is()) { - JsonArrayConst a = v.as(); - if (a.size() >= 3) { - long cc[3]; - for (uint8_t i = 0; i < 3; i++) { - if (a[i].is()) cc[i] = a[i].as(); - else if (a[i].is()) cc[i] = (long)(a[i].as() * 255.999f); - else if (a[i].is()) cc[i] = strtol(a[i].as(), NULL, 0); - else cc[i] = 0; - if (cc[i] > 255) cc[i] = 255; else if (cc[i] < 0) cc[i] = 0; - } - return ((cc[0] & 0xF8) << 8) | ((cc[1] & 0xFC) << 3) | (cc[2] >> 3); - } - if (a.size() >= 1) { - if (a[0].is()) return a[0].as(); - return strtol(a[0].as(), NULL, 0); - } - } - return def; -} - -static void copyStr(char *dst, JsonVariantConst v) { - if (v.is()) { - strncpy(dst, v.as(), EYE_PATH_MAX - 1); - dst[EYE_PATH_MAX - 1] = 0; - } -} - -// Apply one JSON object: the document root, or a per-eye sub-object on top. -static void applyObject(JsonVariantConst o) { - if (o.isNull()) return; - JsonVariantConst v; - - settings.displaySize = dwim(o["displaySize"], settings.displaySize); - settings.eyeRadius = dwim(o["eyeRadius"], settings.eyeRadius); - settings.irisRadius = dwim(o["irisRadius"], settings.irisRadius); - settings.slitPupilRadius = dwim(o["slitPupilRadius"], settings.slitPupilRadius); - settings.gazeMax = (uint32_t)dwim(o["gazeMax"], (int32_t)settings.gazeMax); - - v = o["coverage"]; - if (v.is() || v.is()) settings.coverage = v.as(); - - settings.pupilColor = (uint16_t)dwim(o["pupilColor"], settings.pupilColor); - settings.backColor = (uint16_t)dwim(o["backColor"], settings.backColor); - settings.irisColor = (uint16_t)dwim(o["irisColor"], settings.irisColor); - settings.scleraColor = (uint16_t)dwim(o["scleraColor"], settings.scleraColor); - - // Legacy eyelidIndex expands to a gray via index * 0x0101, which is - // byte-symmetric and so survives the endianness change untouched. A full - // 16-bit eyelidColor is also accepted now that the byte-repeat trick the - // SPI-DMA path relied on is gone. - v = o["eyelidIndex"]; - if (!v.isNull()) settings.eyelidColor = (uint16_t)(dwim(v) & 0xFF) * 0x0101; - v = o["eyelidColor"]; - if (!v.isNull()) settings.eyelidColor = (uint16_t)dwim(v, settings.eyelidColor); - - v = o["pupilMin"]; if (v.is() || v.is()) settings.pupilMin = v.as(); - v = o["pupilMax"]; if (v.is() || v.is()) settings.pupilMax = v.as(); - v = o["tracking"]; if (v.is()) settings.tracking = v.as(); - v = o["squint"]; - if (v.is() || v.is()) settings.trackFactor = 1.0f - v.as(); - - v = o["irisSpin"]; if (v.is() || v.is()) settings.irisSpin = v.as(); - v = o["scleraSpin"]; if (v.is() || v.is()) settings.scleraSpin = v.as(); - - v = o["irisAngle"]; - if (v.is()) settings.irisStartAngle = 1023 - (v.as() & 1023); - else if (v.is()) settings.irisStartAngle = 1023 - ((int)(v.as() * 1024.0f) & 1023); - v = o["scleraAngle"]; - if (v.is()) settings.scleraStartAngle = 1023 - (v.as() & 1023); - else if (v.is()) settings.scleraStartAngle = 1023 - ((int)(v.as() * 1024.0f) & 1023); - - v = o["irisMirror"]; if (v.is() || v.is()) settings.irisMirror = v.as() ? 1023 : 0; - v = o["scleraMirror"]; if (v.is() || v.is()) settings.scleraMirror = v.as() ? 1023 : 0; - v = o["eyelidMirror"]; if (v.is() || v.is()) settings.eyelidMirror = v.as(); - - copyStr(settings.irisFile, o["irisTexture"]); - copyStr(settings.scleraFile, o["scleraTexture"]); - copyStr(settings.upperFile, o["upperEyelid"]); - copyStr(settings.lowerFile, o["lowerEyelid"]); -} - -bool eyeSettingsLoad(const char *filename) { - if (!fsMounted) return false; - File32 f = fatfs.open(filename, FILE_READ); - if (!f) { - Serial.printf("No %s on drive; using built-in defaults\n", filename); - return false; - } - JsonDocument doc; - DeserializationError err = deserializeJson(doc, f); - f.close(); - if (err) { - Serial.printf("Config parse error (%s); using built-in defaults\n", err.c_str()); - return false; - } - applyObject(doc.as()); - applyObject(doc[EYE_SIDE].as()); - Serial.printf("Loaded %s\n", filename); - return true; -} - -void eyeSettingsFinalize(void) { - if (settings.displaySize < 64) settings.displaySize = 64; - if (settings.displaySize > 240) settings.displaySize = 240; - settings.displaySize &= ~1; // Keep even; the renderer halves it - - if (settings.eyeRadius <= 0) settings.eyeRadius = settings.displaySize / 2 + 5; - else settings.eyeRadius = abs(settings.eyeRadius); - - if (settings.irisRadius <= 0) settings.irisRadius = settings.displaySize / 4; - else settings.irisRadius = abs(settings.irisRadius); - // screen2map() takes sqrt(eyeRadius^2 - irisRadius^2); keep it real - if (settings.irisRadius >= settings.eyeRadius) - settings.irisRadius = settings.eyeRadius - 1; - - settings.slitPupilRadius = abs(settings.slitPupilRadius); - if (settings.slitPupilRadius > settings.irisRadius) - settings.slitPupilRadius = settings.irisRadius; - - if (settings.coverage < 0.0f) settings.coverage = 0.0f; - else if (settings.coverage > 1.0f) settings.coverage = 1.0f; - - if (settings.pupilMin < 0.0f) settings.pupilMin = 0.0f; - if (settings.pupilMax > 1.0f) settings.pupilMax = 1.0f; - if (settings.pupilMin > settings.pupilMax) { - float t = settings.pupilMin; - settings.pupilMin = settings.pupilMax; - settings.pupilMax = t; - } - if (settings.trackFactor < 0.0f) settings.trackFactor = 0.0f; - else if (settings.trackFactor > 1.0f) settings.trackFactor = 1.0f; -} - -// =========================================================================== -// 3. STORAGE -// =========================================================================== -// -// Assets live on a FAT volume in the RP2040's QSPI flash, read through -// Adafruit_SPIFlash + SdFat. By default this is the CircuitPython partition -// (see flash_config.h), so the drive is the familiar pre-formatted CIRCUITPY -// volume and M4SK-style eye folders drop straight in. -// -// WHY DRIVE MODE IS A SEPARATE BOOT MODE RATHER THAN CONCURRENT: -// -// Reading is safe. Adafruit_FlashTransport_RP2040 reads through the -// memory-mapped XIP window, which costs nothing and does not disturb video. -// -// Writing is not. Erase and program must disable XIP, which means any code -// or data fetched from flash during that window returns garbage. PicoDVI -// owns core1 and both PIO blocks and runs continuously, so a host write -// landing mid-frame risks a hang. A 4 KB sector erase alone is tens of -// milliseconds. -// -// FAT also wants exclusive access: if the host and the sketch both write, -// the volume corrupts. -// -// So the eye reads its files once at startup and never writes. To change -// files, hold BOOTSEL at reset: DVI never starts, the drive is exported -// read-write over USB, and the board reboots once writing goes quiet. - -static Adafruit_USBD_MSC usb_msc; -static volatile bool mscWritten = false; -static volatile uint32_t lastWriteMillis = 0; - -// These three run in USB interrupt context. Keep them to block I/O only. -static int32_t mscReadCb(uint32_t lba, void *buffer, uint32_t bufsize) { - return flash.readBlocks(lba, (uint8_t *)buffer, bufsize / 512) ? (int32_t)bufsize : -1; -} - -static int32_t mscWriteCb(uint32_t lba, uint8_t *buffer, uint32_t bufsize) { - mscWritten = true; - lastWriteMillis = millis(); - return flash.writeBlocks(lba, buffer, bufsize / 512) ? (int32_t)bufsize : -1; -} - -static void mscFlushCb(void) { - flash.syncBlocks(); - fatfs.cacheClear(); // Our cached view is stale after a host write - lastWriteMillis = millis(); -} - -bool eyeStorageBegin(void) { - if (!flash.begin()) { - Serial.println("Flash chip init failed."); - fsMounted = false; - return false; - } - Serial.printf("Flash JEDEC ID 0x%06lX, %lu bytes\n", - (unsigned long)flash.getJEDECID(), (unsigned long)flash.size()); - - if (!fatfs.begin(&flash)) { - Serial.println("No FAT filesystem found on the flash partition."); - Serial.println("Either load CircuitPython once to create CIRCUITPY, or"); - Serial.println("hold BOOTSEL at reset and let the host format the drive."); - fsMounted = false; - return false; - } - fsMounted = true; - return true; -} - -void eyeStorageEnd(void) { - // Nothing to unmount in the SdFat sense; we simply stop reading. Flash is - // never written in eye mode, so leaving the volume mounted is harmless. - fsMounted = false; -} - -bool eyeStorageDriveModeRequested(void) { - // arduino-pico exposes BOOTSEL as a pseudo-pin. Reading it briefly halts - // XIP, which is harmless here because this runs before DVI starts. - return BOOTSEL; -} - -void eyeStorageRunDriveMode(void) { - Serial.println("=== USB DRIVE MODE ==="); - Serial.println("DVI is intentionally off. Copy files, then eject."); - - if (!flash.begin()) { - Serial.println("Flash chip init failed; cannot export a drive."); - for (;;) delay(1000); - } - // Mount if we can. An unformatted volume is still exported, so the host can - // format it, which is the recovery path when CircuitPython was never loaded. - fsMounted = fatfs.begin(&flash); - if (!fsMounted) Serial.println("Volume not mountable -- format it from the host."); - - usb_msc.setID("Adafruit", "Eye Assets", "1.0"); - usb_msc.setCapacity(flash.size() / 512, 512); - usb_msc.setReadWriteCallback(mscReadCb, mscWriteCb, mscFlushCb); - usb_msc.setUnitReady(true); - usb_msc.begin(); - - Serial.println("Drive exported. Rebooting automatically once writes stop."); - -#ifdef LED_BUILTIN - pinMode(LED_BUILTIN, OUTPUT); -#endif - uint32_t lastBlink = 0; - bool ledState = false; - - for (;;) { - // There is no reliable "ejected" signal, so use quiet time instead: once - // the host has written something and then stayed silent for a couple of - // seconds, the copy is finished and it is safe to restart. - if (mscWritten && ((millis() - lastWriteMillis) > 2000)) { - Serial.println("Writes finished -- rebooting into eye mode."); - flash.syncBlocks(); - delay(250); - rp2040.reboot(); - } - uint32_t now = millis(); - uint32_t period = mscWritten ? 120 : 600; // Fast blink after a write - if ((now - lastBlink) >= period) { - lastBlink = now; - ledState = !ledState; -#ifdef LED_BUILTIN - digitalWrite(LED_BUILTIN, ledState); -#endif - } - delay(5); - } -} - -// =========================================================================== -// 4. TABLES -// =========================================================================== -// -// Adapted from tablegen.cpp. The math is unchanged; sizes come from settings. -// -// The round eyeball is faked with a 2D displacement map rather than real 3D -// rotation. Both tables cover ONE QUADRANT and are mirrored at render time. -// -// STARTUP COST: with a slit pupil, calcMap() runs a brute-force search per -// iris pixel. The RP2040 has no FPU, so expect a few seconds of blank screen. - -uint8_t *displace = NULL; -uint8_t *polarAngle = NULL; -int8_t *polarDist = NULL; -int mapRadius = 0; -int mapDiameter = 0; - -float screen2map(int in) { - return atan2f((float)in, - sqrtf((float)(settings.eyeRadius * settings.eyeRadius - in * in))) / - (float)M_PI_2 * (float)mapRadius; -} - -float map2screen(int in) { - return sinf((float)in / (float)mapRadius) * (float)M_PI_2 * (float)settings.eyeRadius; -} - -static bool calcDisplacement(void) { - const int half = settings.displaySize / 2; - displace = (uint8_t *)malloc(half * half); - if (!displace) return false; - - const float eyeRadius2 = (float)(settings.eyeRadius * settings.eyeRadius); - uint8_t *ptr = displace; - - // First quadrant only, "+Y is up". Pixel centers at +0.5 by design; that - // makes mirroring numerically correct. - for (int y = 0; y < half; y++) { - float dy = (float)y + 0.5f; - dy *= dy; - for (int x = 0; x < half; x++) { - float dx = (float)x + 0.5f; - float d2 = dx * dx + dy; - if (d2 <= eyeRadius2) { - float d = sqrtf(d2); - float h = sqrtf(eyeRadius2 - d2); // Hemisphere height at d - float a = atan2f(d, h); // 0 to pi/2 from center - float pa = a / (float)M_PI_2 * (float)mapRadius; - dx /= d; - *ptr++ = (uint8_t)(dx * pa) - x; - } else { - *ptr++ = 255; // Outside the eye - } - } - } - return true; -} - -static bool calcMap(void) { - const int pixels = mapRadius * mapRadius; - - polarAngle = (uint8_t *)malloc(pixels * 2); // One alloc for both tables - if (!polarAngle) return false; - polarDist = (int8_t *)&polarAngle[pixels]; - - const float mapRadius2 = (float)mapRadius * (float)mapRadius; - const float iRad = screen2map(settings.irisRadius); - const float irisRadius2 = iRad * iRad; - - uint8_t *anglePtr = polarAngle; - int8_t *distPtr = polarDist; - - for (int y = 0; y < mapRadius; y++) { - float dy = (float)y + 0.5f, dy2 = dy * dy; - for (int x = 0; x < mapRadius; x++) { - float dx = (float)x + 0.5f; - float d2 = dx * dx + dy2; - if (d2 > mapRadius2) { - *anglePtr++ = 0; - *distPtr++ = -128; - } else { - float angle = (float)M_PI_2 - atan2f(dy, dx); // Clockwise, 0 at top - angle *= 512.0f / (float)M_PI; // 0 to <256 in Q1 - *anglePtr++ = (uint8_t)angle; - float d = sqrtf(d2); - if (d2 > irisRadius2) { // Sclera: 0..127 - d = ((float)mapRadius - d) / ((float)mapRadius - iRad); - *distPtr++ = (int8_t)(d * 127.0f); - } else { // Iris: -1..-127 - d = (iRad - d) / iRad; - *distPtr++ = (int8_t)(d * -127.0f) - 1; - } - } - } - } - - if (settings.slitPupilRadius > 0) { - for (int y = 0; y < mapRadius; y++) { - float dy = (float)y + 0.5f, dy2 = dy * dy; - for (int x = 0; x < mapRadius; x++) { - float dx = (float)x + 0.5f; - float d2 = dx * dx + dy2; - if (d2 > irisRadius2) continue; - float xp = (float)x + 0.5f; - for (int i = 126; i >= 0; i--) { - float ratio = (float)i / 128.0f; // 0.0 open .. just under 1.0 slit - // A point between top of iris and top of slit pupil, and another - // between right of iris and center; find the circle through both. - float y1 = iRad - (iRad - (float)settings.slitPupilRadius) * ratio; - float x2 = iRad * (1.0f - ratio); - float xc = (x2 * x2 - y1 * y1) / (2.0f * x2); - float rx = x2 - xc; - float px = xp - xc; - if ((px * px + dy2) <= (rx * rx)) { - polarDist[y * mapRadius + x] = (int8_t)(-1 - i); - break; - } - } - } - } - } - return true; -} - -void eyeTablesFree(void) { - if (polarAngle) { free(polarAngle); polarAngle = NULL; polarDist = NULL; } - if (displace) { free(displace); displace = NULL; } -} - -bool eyeTablesInit(void) { - eyeTablesFree(); - mapRadius = (int)((float)settings.eyeRadius * (float)M_PI * settings.coverage + 0.5f); - mapDiameter = mapRadius * 2; - if (mapRadius < 8) return false; - if (!calcMap()) { eyeTablesFree(); return false; } - if (!calcDisplacement()) { eyeTablesFree(); return false; } - return true; -} - -// =========================================================================== -// 5. MEDIA -// =========================================================================== -// -// Everything is optional. A missing texture becomes a 1x1 buffer holding the -// solid color from settings, which the renderer samples correctly and which -// still produces a properly sized, dilating pupil. A missing eyelid leaves -// the sweep tables at their init values, which reads as "no eyelid". - -uint8_t *upperOpen = NULL, *upperClosed = NULL; -uint8_t *lowerOpen = NULL, *lowerClosed = NULL; - -const uint16_t *irisData = NULL, *scleraData = NULL; -static uint16_t s_irisW = 0, s_irisH = 0, s_scleraW = 0, s_scleraH = 0; -static uint16_t s_irisSolid = 0, s_scleraSolid = 0; // 1x1 fallback storage - -uint16_t irisWidth(void) { return s_irisW; } -uint16_t irisHeight(void) { return s_irisH; } -uint16_t scleraWidth(void) { return s_scleraW; } -uint16_t scleraHeight(void) { return s_scleraH; } - -// Adapter so the BMP loaders can read an SdFat File32. Note seekSet() rather -// than seek(), and read() returns a signed count (-1 on error). -class FileBmpReader : public BmpReader { - File32 f; -public: - explicit FileBmpReader(const char *path) { - if (fsMounted) f = fatfs.open(path, FILE_READ); - } - ~FileBmpReader() { if (f) f.close(); } - bool ok() const { return (bool)f; } - bool seek(uint32_t pos) override { return f && f.seekSet(pos); } - size_t read(void *buf, size_t len) override { - if (!f) return 0; - int n = f.read(buf, len); - return (n < 0) ? 0 : (size_t)n; - } -}; - -static void loadOneEyelid(const char *path, uint8_t *openT, uint8_t *closedT, - int size, bool isUpper) { - const char *label = isUpper ? "upper" : "lower"; - if (path && path[0]) { - FileBmpReader r(path); - if (r.ok() && bmpLoadEyelid(r, openT, closedT, size, isUpper)) { - Serial.printf(" %s eyelid: %s\n", label, path); - return; - } - Serial.printf(" %s eyelid: %s unusable -- no eyelid\n", label, path); - } else { - Serial.printf(" %s eyelid: none specified\n", label); - } - // Init values mean "lid fully out of the way" - memset(openT, isUpper ? (uint8_t)(size - 1) : 0, size); - memset(closedT, isUpper ? (uint8_t)(size - 1) : 0, size); -} - -static bool loadOneTexture(const char *path, const uint16_t **data, - uint16_t *w, uint16_t *h, uint32_t budget, - uint16_t *solidStore, uint16_t solidColor, - const char *label) { - if (path && path[0] && budget > 512) { - FileBmpReader r(path); - uint16_t *loaded = NULL; - if (r.ok() && bmpLoadTexture(r, &loaded, w, h, budget)) { - *data = loaded; - Serial.printf(" %s: %s -> %ux%u (%u bytes)\n", label, path, *w, *h, - (unsigned)(*w * *h * 2)); - return true; - } - Serial.printf(" %s: %s unusable -- solid color\n", label, path); - } else if (path && path[0]) { - Serial.printf(" %s: no RAM for %s -- solid color\n", label, path); - } else { - Serial.printf(" %s: none specified -- solid color\n", label); - } - *solidStore = solidColor; // 1x1 texture, exactly as the original does - *data = solidStore; - *w = *h = 1; - return false; -} - -bool eyeMediaLoad(int size, uint32_t texBudget) { - uint8_t *block = (uint8_t *)malloc((size_t)size * 4); // All four tables - if (!block) return false; - upperOpen = &block[0]; - upperClosed = &block[size]; - lowerOpen = &block[size * 2]; - lowerClosed = &block[size * 3]; - - Serial.println("Media:"); - loadOneEyelid(settings.upperFile, upperOpen, upperClosed, size, true); - loadOneEyelid(settings.lowerFile, lowerOpen, lowerClosed, size, false); - - // The sclera is usually a thin gradient; give the iris nearly everything. - uint32_t scleraBudget = texBudget / 8; - if (scleraBudget > 4096) scleraBudget = 4096; - - loadOneTexture(settings.irisFile, &irisData, &s_irisW, &s_irisH, - texBudget - scleraBudget, &s_irisSolid, settings.irisColor, - "iris"); - loadOneTexture(settings.scleraFile, &scleraData, &s_scleraW, &s_scleraH, - scleraBudget, &s_scleraSolid, settings.scleraColor, "sclera"); - return true; -} diff --git a/PicoDVI_Eyes/flash_config.h b/PicoDVI_Eyes/flash_config.h deleted file mode 100644 index 31b127c..0000000 --- a/PicoDVI_Eyes/flash_config.h +++ /dev/null @@ -1,40 +0,0 @@ -// Flash transport selection, adapted from Adafruit's flash_config.h. -// -// PARTITION CHOICE MATTERS. The RP2040's QSPI flash holds both the program -// and the filesystem, and the two schemes place the filesystem differently: -// -// Adafruit_FlashTransport_RP2040 uses the partition defined by -// Tools > Flash Size (at the END of -// flash) -// Adafruit_FlashTransport_RP2040_CPY uses CircuitPython's layout -// (start 1 MB, size = total - 1 MB) -// -// The CPY layout is the default here, because it gives you the familiar -// pre-formatted CIRCUITPY drive and matches how M4SK eye projects are laid -// out. With it, set Tools > Flash Size to a "FS 0MB" option so the core does -// not also claim a region at the end of flash and overlap. -// -// Keep the sketch under 1 MB (this one is a few hundred KB) so it cannot -// collide with the filesystem start. - -#ifndef FLASH_CONFIG_H_ -#define FLASH_CONFIG_H_ - -#include - -// Comment this out to use the arduino-pico Tools > Flash Size partition -// instead of the CircuitPython one. -#define USE_CIRCUITPY_PARTITION 1 - -#if !defined(ARDUINO_ARCH_RP2040) - #error "This build targets RP2040 / RP2350." -#endif - -#if defined(USE_CIRCUITPY_PARTITION) - Adafruit_FlashTransport_RP2040_CPY flashTransport; -#else - // (start=0, size=0) means "match the Tools > Flash Size selection" - Adafruit_FlashTransport_RP2040 flashTransport; -#endif - -#endif // FLASH_CONFIG_H_