Finished up most documentation

This commit is contained in:
2026-09-10 21:11:07 -07:00
parent 2cfa0ca690
commit 8355743ada
14 changed files with 1871 additions and 868 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -220,6 +220,10 @@ Questions to resolve:
A strong candidate direction is to keep negative-habit recovery entirely outside the card economy, but this still needs an explicit decision. A strong candidate direction is to keep negative-habit recovery entirely outside the card economy, but this still needs an explicit decision.
#### ANSWER:
Negative habits are tracked separately and do not directly affect card rewards. Abstinence, honest reporting, recovery milestones, and returning after failure receive non-economic recognition only. Positive replacement behaviors may be tracked separately as ordinary reward-eligible habits.
--- ---
### 8. How far back can reward-eligible logging go? ### 8. How far back can reward-eligible logging go?
@@ -238,6 +242,11 @@ Questions to resolve:
- What happens if yesterday's habits are edited after its pack was opened? - What happens if yesterday's habits are edited after its pack was opened?
- Should completed entries become immutable after the reward is claimed? - Should completed entries become immutable after the reward is claimed?
#### ANSWER
Reward-eligible habit completion may be logged through the end of the following calendar day. During this grace period, completion changes may continue to add or remove cards from an unopened daily pack. Opening the pack permanently finalizes its reward state. When the grace period expires, an unopened pack automatically finalizes at its current size and may be saved and opened later. Older habits remain editable for history and consistency purposes but cannot alter card rewards.
--- ---
## Tier 2 — Economy and Collection Design ## Tier 2 — Economy and Collection Design
@@ -256,6 +265,63 @@ Questions to resolve:
This needs simulation rather than intuition alone. This needs simulation rather than intuition alone.
#### ANSWER:
Normal daily and weekly pack slots use the same global rarity distribution. Habit completion increases the number of cards in a pack but does not improve the rarity odds of any individual card.
##### Pack sizes
**Daily pack**
* Login / start of day: 2 cards.
* Each completed reward-eligible daily habit adds 2 cards.
* Up to 5 daily habits may be reward-eligible.
* Maximum daily pack: 12 cards.
**Weekly pack**
* No automatic login cards.
* Each completed reward-eligible weekly habit adds 2 cards.
* Up to 3 weekly habits may be reward-eligible.
* Maximum weekly pack: 6 cards.
Monthly habits do not generate cards in M1.
##### M1 rarity distribution
Each card slot independently rolls against the following global rarity table:
| Rarity | Pull Probability |
| ------------- | ---------------: |
| Common | 59.00% |
| Uncommon | 28.50% |
| Rare | 9.00% |
| Extraordinary | 2.75% |
| Legendary | 0.75% |
| **Total** | **100.00%** |
These probabilities are separate from the percentage of card identities assigned to each rarity tier.
The initial 500-card catalog currently contains:
| Rarity | Card Identities |
| ------------- | --------------: |
| Common | 192 |
| Uncommon | 150 |
| Rare | 101 |
| Extraordinary | 39 |
| Legendary | 18 |
| **Total** | **500** |
Within a rarity tier, eligible card identities initially have equal probability unless a later set-specific mechanic explicitly defines otherwise.
Normal packs do not contain guaranteed Rare+, Extraordinary+, or Legendary slots. Every card slot is an independent draw from the same rarity distribution.
The purpose of completing additional habits is therefore to receive more draws, not better hidden odds.
These values are the **M1 starting economy** rather than permanent constants. They should be configurable and validated through economy simulation and playtesting. Any future rebalance should remain global and transparent rather than dynamically manipulating probabilities for individual users.
--- ---
### 10. What does "about a year to complete the collection" mean? ### 10. What does "about a year to complete the collection" mean?
@@ -275,6 +341,31 @@ Questions to resolve:
- How much should trade-ups accelerate completion? - How much should trade-ups accelerate completion?
- What completion target should be used for economy simulation? - What completion target should be used for economy simulation?
#### ANSWER:
Collection completion is measured by base card identity. A user needs at least one instance of each base card to complete the base compendium. Finish, printing, material, condition, imperfections, and other instance-level properties do not affect base-compendium completion.
The M1 economy should not be designed around guaranteeing 100% natural completion after a fixed period. Random duplicates intentionally create a progressively slower collection curve.
Using the initial 500-card catalog and M1 rarity distribution, the target experience for a maximally consistent user opening normal rewards is approximately:
50% completion after about 4 weeks.
75% completion after about 9–10 weeks.
90% completion after about 19 weeks.
95% completion after about 28 weeks.
Approximately 99% completion after about one year.
These are economy-model targets rather than guarantees for individual users.
The final few percent of the compendium should represent a substantially longer collector challenge when relying only on random packs. Normal pack opening does not provide duplicate protection or dynamically favor cards missing from the user's collection.
Future mechanics such as trading and trade-ups may provide more directed ways to acquire missing cards and complete the compendium.
Variant collecting is intentionally much deeper than base-compendium completion. Obtaining one copy of a card identity does not imply that the user has exhausted the collectible possibilities associated with that card.
Whether filler cards participate in base-compendium completion is decided separately under the filler-card economy.
--- ---
### 11. How do filler cards work economically? ### 11. How do filler cards work economically?
@@ -293,6 +384,11 @@ Questions to resolve:
- Whether filler cards can have finish/material/printing variants. - Whether filler cards can have finish/material/printing variants.
- Whether fillers should be visually recognizable as a special classification. - Whether fillers should be visually recognizable as a special classification.
#### ANSWER
DEFERRED FOR M1
--- ---
### 12. How do trade-ups / crafting work? ### 12. How do trade-ups / crafting work?
@@ -316,6 +412,110 @@ Questions to resolve:
- Whether destroyed inputs remain visible in provenance history. - Whether destroyed inputs remain visible in provenance history.
- Whether trade-ups are the primary card sink or one of several sinks. - Whether trade-ups are the primary card sink or one of several sinks.
#### ANSWER
Status: Resolved for M1
M1 will include a straightforward 10-to-1 rarity trade-up system.
A trade-up consumes ten card instances of the same rarity and permanently generates one card of the immediately higher rarity:
10 Common → 1 Uncommon
10 Uncommon → 1 Rare
10 Rare → 1 Extraordinary
10 Extraordinary → 1 Legendary
Trade-ups cannot skip rarity tiers, and the rarity upgrade is guaranteed. The random outcome is the resulting card identity and its instance properties, not whether the trade-up succeeds.
Card identity
For M1, the resulting card identity is selected randomly from the eligible cards in the next rarity tier.
The identities of the input cards do not influence the resulting identity.
Set-specific or category-targeted trade-ups may be considered later but are not part of the initial mechanic.
Instance-property inheritance
The finish, material, and printing of the input cards directly influence the corresponding properties of the resulting card.
Each input contributes an equal share of probability.
For a ten-card trade-up, each input therefore contributes 10 percentage points to its represented property.
For example:
Finish
10 Holographic
→ 100% Holographic output
7 Holographic
3 Matte
→ 70% Holographic
→ 30% Matte
The same system independently applies to material and printing.
For example:
Material
5 Metal
3 Paper
2 Linen
→ 50% Metal
→ 30% Paper
→ 20% Linen
and:
Printing
8 Normal
2 Borderless
→ 80% Normal
→ 20% Borderless
Each property is rolled independently. This means a trade-up can produce combinations that were not present on any single input card.
For example, five Holographic/Paper cards and five Matte/Metal cards could potentially produce a Holographic/Metal result.
This is intentional. Trade-ups act as a way to combine desirable collectible traits as well as increase rarity.
Guaranteed property inheritance
If all ten input cards share a property, that property is guaranteed on the resulting card.
Examples:
10 Holographic cards → guaranteed Holographic.
10 Metal cards → guaranteed Metal.
10 Borderless cards → guaranteed Borderless.
Inputs sharing all three properties guarantee all three properties on the output.
This allows collectors to deliberately construct trade-up recipes rather than treating every duplicate of the same rarity as economically identical.
Destruction
All ten input cards are permanently consumed by the trade-up and are no longer part of the active card population or any user's collection.
Their historical provenance should not be erased. We could have a counter of how many of the different cards you have traded up but we don't want to have a huge DB of dead cards. So a basic counter
Design intent
Trade-ups serve two purposes:
Provide a meaningful use for duplicates and permanently remove cards from circulation.
Allow low-rarity cards with desirable finishes, materials, printings, or condition to retain meaningful collector and crafting value.
A highly desirable Common card is therefore not automatically disposable merely because its base identity is Common. Its instance properties can make it valuable either as a collectible or as an ingredient in a deliberately constructed trade-up.
More advanced crafting systems, targeted card identities, targeted sets, and additional recipes are deferred until after the M1 economy has been tested.
--- ---
### 13. What role does voluntary card burning actually serve? ### 13. What role does voluntary card burning actually serve?
@@ -338,6 +538,12 @@ Questions to resolve:
- Does a burned card remain visible in historical records? - Does a burned card remain visible in historical records?
- Is the religious symbolism appropriate and useful, or is this simply an economy mechanic with a dramatic animation? - Is the religious symbolism appropriate and useful, or is this simply an economy mechanic with a dramatic animation?
#### ANSWER
I think for now it would be a nice animation for the card trade up. Like the 10 cards burn together to form the new one
Other aspects we can defer.
--- ---
### 14. When are saved-pack contents determined? ### 14. When are saved-pack contents determined?
@@ -356,6 +562,42 @@ Questions to resolve:
The implementation should be deterministic enough that server/client behavior cannot accidentally reroll contents. The implementation should be deterministic enough that server/client behavior cannot accidentally reroll contents.
#### ANSWER
The contents of a pack are generated when that pack is finalized, not when it is opened.
Finalization occurs when:
The user opens an active pack early.
The pack has reached its maximum possible reward state.
The pack's reward-eligible grace period expires.
When a pack finalizes, the system determines and stores all resulting card instances, including:
Card identity.
Rarity.
Finish.
Material.
Printing.
Condition.
Any other instance-level properties enabled by the current economy.
Opening a pack does not generate or reroll its contents. It only reveals card instances that were already created when the pack finalized.
This ensures that:
Saved packs are not affected by future rarity or economy rebalancing.
Opening cannot be retried to obtain different results.
Client interruptions during the reveal process cannot change awarded cards.
Population and provenance can be tracked consistently.
A saved unopened pack may therefore remain unopened indefinitely without changing its contents.
Card provenance should distinguish between the time an instance was created/finalized and the time it was actually revealed to the user.
For an active pack opened before its reward period ends, confirming the open action first finalizes the pack at its current card count, generates its contents, and then begins the reveal experience.
--- ---
## Tier 3 — What Constitutes a Card ## Tier 3 — What Constitutes a Card
@@ -383,6 +625,20 @@ Questions to resolve:
- Presentation. - Presentation.
- Whether filler remains outside the rarity hierarchy. - Whether filler remains outside the rarity hierarchy.
#### ANSWER
Common
Uncommon
Rare
Extraordinary
Legendary
Card rarity = identity prominence / pull scarcity
Instance rarity = not a separate thing
Finish/material/printing/condition = independent collectible properties
--- ---
### 16. What does rarity mean across different sets? ### 16. What does rarity mean across different sets?
@@ -399,6 +655,13 @@ Questions to resolve:
- Does a card identity have one canonical rarity or can each printing/set version have its own? - Does a card identity have one canonical rarity or can each printing/set version have its own?
- How should rarity be communicated so users do not interpret it as spiritual importance? - How should rarity be communicated so users do not interpret it as spiritual importance?
#### ANSWER
A Legendary card therefore participates in the same Legendary rarity tier regardless of whether it represents a Biblical person, historical event, doctrine, tradition-specific subject, or another category.
Sets do not define independent rarity probabilities in M1.
--- ---
### 17. Are finish, printing, and material fully combinatorial? ### 17. Are finish, printing, and material fully combinatorial?
@@ -424,6 +687,13 @@ Questions to resolve:
- Do special combinations have different population tracking? - Do special combinations have different population tracking?
- Should invalid combinations be encoded as rules or simply never generated? - Should invalid combinations be encoded as rules or simply never generated?
#### ANSWER
Finish, material, and printing are independent collectible properties and are combinatorial by default.
Unusual combinations are not considered invalid merely because they would be uncommon or difficult to manufacture as physical cards. Sanctification TCG is a digital collectible system, and unusual combinations are desirable when the renderer can present them convincingly.
--- ---
### 18. What exactly is a printing? ### 18. What exactly is a printing?
@@ -448,6 +718,11 @@ Questions to resolve:
- Whether alternate artwork is itself a printing or a separate artwork property. - Whether alternate artwork is itself a printing or a separate artwork property.
- Whether tradition-specific versions are printings, separate cards, or artwork variants. - Whether tradition-specific versions are printings, separate cards, or artwork variants.
#### ANSWER
Printing is an independent collectible property that may materially alter a card’s artwork composition, framing, layout, or text treatment. Exact printing types, visual definitions, compatibility rules, and art-production requirements are defined in the Art Direction specification.
--- ---
### 19. What is the finish taxonomy? ### 19. What is the finish taxonomy?
@@ -470,6 +745,10 @@ Questions to resolve:
- Are finish variants purely visual, or do they also affect rarity/value? - Are finish variants purely visual, or do they also affect rarity/value?
- Do finishes alter simulated weight? - Do finishes alter simulated weight?
#### ANSWER
Will be in the art direction
--- ---
### 20. What is the material taxonomy? ### 20. What is the material taxonomy?
@@ -493,6 +772,10 @@ Questions to resolve:
- Edge wear behavior. - Edge wear behavior.
- Whether unusual materials should be extremely rare or simply another independent variant dimension. - Whether unusual materials should be extremely rare or simply another independent variant dimension.
#### ANSWER
Each card instance has exactly one material representing its physical substrate. M1 candidate materials are Paper, Linen, Wood, and Metal. Material is independent of base rarity, participates in card generation, population grouping, and trade-up inheritance, and may have its own economy probabilities. Exact visual treatment, physical appearance, compatibility constraints, thickness, lighting response, and renderer behavior are defined in the Art Direction specification. Paper would be considered the default
--- ---
### 21. How does condition / wear work? ### 21. How does condition / wear work?
@@ -516,6 +799,18 @@ Questions to resolve:
- How much condition should affect collector desirability. - How much condition should affect collector desirability.
- Lowest acceptable visual state. - Lowest acceptable visual state.
#### ANSWER
Randomized wear or condition will not be an instance-level collectible property in M1.
Earlier designs assigned every card a continuous condition value and rendered varying levels of scratches, edge wear, scuffs, and other damage. This creates undesirable negative variance: a user may obtain a rare card identity with exactly the finish, material, and printing they wanted, only for the card to feel inferior because of an additional condition roll.
Consider maybe some other finishes:
Weathered
Antiqued
Distressed parchment
Patinated metal
--- ---
### 22. Do imperfections belong in the product? ### 22. Do imperfections belong in the product?
@@ -536,6 +831,10 @@ Questions to resolve:
- Do users need a way to inspect/identify them explicitly? - Do users need a way to inspect/identify them explicitly?
- Should the MVP omit them until the rest of the variant system is proven? - Should the MVP omit them until the rest of the variant system is proven?
#### ANSWER
CUT
--- ---
### 23. How should population be counted? ### 23. How should population be counted?
@@ -548,8 +847,6 @@ Questions to resolve:
- Whether artwork belongs in the population key. - Whether artwork belongs in the population key.
- Whether set belongs in the population key. - Whether set belongs in the population key.
- Whether condition should remain excluded.
- Whether imperfections remain excluded.
- Whether population numbers are: - Whether population numbers are:
- Global. - Global.
- Public. - Public.
@@ -561,6 +858,11 @@ Questions to resolve:
- Total ever opened. - Total ever opened.
- Both. - Both.
#### ANSWER
Population is tracked for meaningful collectible variants using card identity + artwork variant + finish + material + printing. The system records both total instances ever created and the number currently surviving. Burning and trade-ups reduce surviving population but do not erase historical creation counts. Provenance, ownership, and timestamps do not create separate populations. Public visibility of population statistics is decided separately.
--- ---
### 24. Does simulated weight stay in the product? ### 24. Does simulated weight stay in the product?
@@ -581,6 +883,11 @@ Questions to resolve later:
This is intentionally low priority. This is intentionally low priority.
#### ANSWER
Each card instance has a simulated physical weight derived primarily from its material, with small deterministic variation and optional minor adjustments from finish/printing.
--- ---
## Tier 4 — The Trinity Card ## Tier 4 — The Trinity Card
@@ -608,6 +915,10 @@ Questions to resolve:
- Another title. - Another title.
- Whether this is technically a collectible card, foundation card, or a separate object type. - Whether this is technically a collectible card, foundation card, or a separate object type.
#### ANSWER
Deferred for now
--- ---
### 26. Which Scripture belongs on the Trinity card? ### 26. Which Scripture belongs on the Trinity card?
@@ -627,6 +938,10 @@ Questions to resolve:
- God's love and salvation? - God's love and salvation?
- Whether a verse is sufficient or a short doctrinal text is more appropriate. - Whether a verse is sufficient or a short doctrinal text is more appropriate.
#### ANSWER
Deferred for now
--- ---
### 27. What should the Trinity artwork depict? ### 27. What should the Trinity artwork depict?
@@ -644,6 +959,10 @@ Questions to resolve:
- Whether any traditional Trinitarian iconography is appropriate. - Whether any traditional Trinitarian iconography is appropriate.
- How to avoid accidentally privileging one tradition's visual theology. - How to avoid accidentally privileging one tradition's visual theology.
#### ANSWER
Deferred for now
--- ---
### 28. How customizable is the Trinity card? ### 28. How customizable is the Trinity card?
@@ -663,6 +982,10 @@ Questions to resolve:
- Whether customization changes provenance. - Whether customization changes provenance.
- Whether a customized Trinity card remains one canonical instance. - Whether a customized Trinity card remains one canonical instance.
#### ANSWER
Deferred for now
--- ---
## Tier 5 — Christian Content and Editorial Policy ## Tier 5 — Christian Content and Editorial Policy
@@ -679,6 +1002,25 @@ Questions to resolve:
- Is the application presenting a doctrinal position or merely using one as an organizational framework? - Is the application presenting a doctrinal position or merely using one as an organizational framework?
- What happens with traditions that accept the Nicene Creed but interpret later doctrines very differently? - What happens with traditions that accept the Nicene Creed but interpret later doctrines very differently?
#### ANSWER
Sanctification TCG uses Nicene Christianity as its doctrinal baseline.
For the purposes of the application, beliefs and movements that fall outside the core theology expressed by the Nicene Creed are considered outside orthodox Christianity and may be identified as heretical or non-Nicene where historically and theologically appropriate.
This boundary is not intended to prevent the application from representing or teaching about beliefs outside it. Non-Nicene movements, historical heresies, disputed teachings, and other religious traditions may still appear in the card catalog when they are educationally or historically relevant.
Their treatment should remain accurate, charitable, and informative. Cards should explain:
What the person, movement, or teaching actually believed.
Its historical and theological context.
Why it was or is considered outside the Nicene standard.
How its beliefs differ from the doctrinal baseline used by the application.
The goal is clarity without mockery. The application does not need to present every theological position as equally compatible with Christianity in order to describe those positions fairly.
Disagreements that exist within Nicene Christianity should be distinguished from teachings that cross the Nicene boundary. Catholic, Orthodox, Protestant, and other Nicene traditions may disagree substantially on later doctrines and practices while remaining inside the application's shared Christian baseline.
--- ---
### 30. How deep do tradition-specific collections go? ### 30. How deep do tradition-specific collections go?
@@ -709,6 +1051,10 @@ Questions to resolve:
- All of the above. - All of the above.
- Whether cards can belong to multiple traditions. - Whether cards can belong to multiple traditions.
#### ANSWER
Already completed in master catalog
--- ---
### 31. How are controversies and non-Nicene movements classified? ### 31. How are controversies and non-Nicene movements classified?
@@ -736,6 +1082,27 @@ Questions to resolve:
- How labels are chosen without turning cards into insult cards. - How labels are chosen without turning cards into insult cards.
- Who approves wording. - Who approves wording.
#### ANSWER
* Intra-Nicene controversy — disputes between traditions that remain inside the doctrinal baseline.
* Historical heresy — teachings historically condemned as contrary to core Christian doctrine.
* Schism / ecclesial controversy — primarily disputes over communion, authority, jurisdiction, or church structure rather than rejection of Nicene doctrine.
* Non-Nicene movement — later movements or religious bodies whose theology falls outside the Nicene baseline.
Cards dealing with controversial teachings or movements should be educational rather than insulting or polemical.
Where practical, their content should explain:
What the person, movement, or teaching actually believed or taught.
Its historical context.
Why the issue became controversial.
Whether the disagreement falls inside or outside the application's Nicene baseline.
How the Nicene position differs when the subject falls outside that baseline.
Disagreements within Nicene Christianity should be represented as genuine disagreements without implying that one side automatically falls outside Christianity unless the underlying issue actually crosses the Nicene boundary.
The goal is doctrinal clarity, historical accuracy, and charitable presentation.
--- ---
### 32. Who reviews doctrinal and historical accuracy? ### 32. Who reviews doctrinal and historical accuracy?
@@ -754,6 +1121,10 @@ Questions to resolve:
- How disagreements between credible traditions are presented. - How disagreements between credible traditions are presented.
- Whether the app should explicitly distinguish consensus from disputed interpretation. - Whether the app should explicitly distinguish consensus from disputed interpretation.
#### ANSWER
Deferred for now. When we get to the writing, we'll deal with it then
--- ---
### 33. What is the citation/source policy? ### 33. What is the citation/source policy?
@@ -773,6 +1144,77 @@ Questions to resolve:
- How sources are versioned if card text changes. - How sources are versioned if card text changes.
- Whether citations appear directly on the card or only in the detailed information panel. - Whether citations appear directly on the card or only in the detailed information panel.
#### ANSWER
Card content should be supported by source metadata sufficient for users to understand where factual, historical, scriptural, and doctrinal claims come from.
The card face should remain visually concise. Full citations and supporting resources should generally appear in the card's expanded information or learning panel rather than crowding the primary card layout.
Source priority
Where applicable, sources should be prioritized approximately as follows:
Scripture, when directly relevant.
Primary historical or theological sources, such as creeds, council texts, letters, sermons, confessions, catechisms, or writings by the person being represented.
Tradition-specific authoritative sources, when explaining the teaching of a specific Christian tradition.
Reputable secondary historical or scholarly sources, for historical context, chronology, interpretation, or claims that cannot be established from a primary source alone.
Research tools, general websites, and tertiary summaries may assist content creation but should not normally serve as the sole published authority for important doctrinal or historical claims when stronger sources are available.
Card-face references
The card itself may contain concise references when useful, such as:
Scripture citations.
Short quotation attribution.
Council/date references.
Author/work references.
Full bibliographic details are not required on the visible card face.
Expanded information panel
The expanded card view should provide access to relevant source information and further reading.
This may include:
Scripture references.
Primary-source citations.
Historical references.
Tradition-specific documents.
Secondary sources.
Further-reading links or resources.
Quotations
Direct quotations require clear attribution.
Where practical, quote metadata should include:
Author or speaker.
Source work.
Relevant section, chapter, paragraph, or page.
Translation or edition where the wording depends materially on it.
Quotation attribution should be verified before publication.
Disputed claims
When historical, theological, or attribution questions are genuinely disputed, the application should represent that uncertainty rather than presenting one interpretation as uncontested fact.
The information panel may identify:
Traditional attribution.
Scholarly disagreement.
Uncertain dates.
Different interpretations among Nicene traditions.
Data model
Sources belong primarily to the card definition, not the individual card instance.
Finish, material, printing, provenance, and other instance-level properties do not normally change the underlying citation set.
A future alternate printing or artwork version that contains materially different text may reference additional or different sources where necessary.
--- ---
### 34. What religious-art representation rules are needed? ### 34. What religious-art representation rules are needed?
@@ -799,6 +1241,24 @@ Questions to resolve:
- What requires theological/art review. - What requires theological/art review.
- Whether some concepts should use symbolic rather than figurative art. - Whether some concepts should use symbolic rather than figurative art.
#### ANSWER
**Status: Deferred primarily to Art Direction**
Detailed rules for depicting Jesus, the Father, the Holy Spirit, angels, saints, icons, biblical figures, crucifixion scenes, resurrection scenes, and other sacred subjects belong in the Art Direction specification.
The functional specification should retain only the following principles:
* Religious artwork should be treated respectfully and intentionally.
* The application should avoid irreverent, trivializing, or unnecessarily sensational depictions of sacred subjects.
* Where Christian traditions have materially different artistic conventions, the application may use tradition-specific visual treatments rather than forcing every subject into one universal style.
* Symbolic or non-figurative representation may be used where a literal depiction would be inappropriate, uncertain, or theologically sensitive.
* Historical or traditional iconography may inform artwork when appropriate to the card and collection.
* Artistic representation should not imply greater spiritual worth based on card rarity.
* Exact depiction rules, iconographic references, prohibited treatments, and tradition-specific art guidance are maintained in the Art Direction document.
The Trinity/Foundation card remains a special case whose exact imagery is deferred separately.
--- ---
## Tier 6 — Privacy, Accountability, and Social ## Tier 6 — Privacy, Accountability, and Social
@@ -828,6 +1288,14 @@ Questions to resolve:
This will likely require a dedicated technical-spec decision after the functional expectations are settled. This will likely require a dedicated technical-spec decision after the functional expectations are settled.
#### ANSWER
Sensitive user-authored habit content is encrypted client-side before being stored or synchronized. The backend stores only the minimum unencrypted metadata necessary to operate schedules, rewards, accounts, and explicitly initiated social features. The server should not possess the information necessary to casually inspect private habit names, negative habits, notes, or detailed accountability content.
Negative-habit content and history should receive the strongest protection because they do not participate in the card economy and may contain particularly sensitive information.
The exact cryptographic design, key wrapping, multi-device synchronization, and recovery mechanism belong in the technical/security specification. BYOK is cut. Account recovery must not silently undermine the stated privacy guarantees.
--- ---
### 36. What are the exact accountability permissions? ### 36. What are the exact accountability permissions?
@@ -853,6 +1321,10 @@ Questions to resolve:
- Whether access is permanent or temporary. - Whether access is permanent or temporary.
- Whether users can revoke previously-shared information. - Whether users can revoke previously-shared information.
#### ANSWER
DEFERRED FOR NOW
--- ---
### 37. What triggers an accountability / prayer signal? ### 37. What triggers an accountability / prayer signal?
@@ -870,6 +1342,10 @@ Questions to resolve:
Privacy suggests manual or explicitly-configured behavior rather than implicit disclosure. Privacy suggests manual or explicitly-configured behavior rather than implicit disclosure.
#### ANSWER
DEFERRED FOR NOW
--- ---
### 38. What collection metadata is public? ### 38. What collection metadata is public?
@@ -879,21 +1355,29 @@ Potential metadata:
- Binder. - Binder.
- Card ownership. - Card ownership.
- Finish/material/printing. - Finish/material/printing.
- Condition.
- Original opener. - Original opener.
- Opening date. - Opening date.
- Population. - Population.
- Trade history. - Trade history.
- Destroyed-card history.
Questions to resolve: Questions to resolve:
- Default visibility. - Default visibility.
- Per-user privacy controls. - Per-user privacy controls.
- Whether original-opener identity persists after trading. - Whether original-opener identity persists after trading. - YES (nice to know who generated)
- Whether users can anonymize provenance. - Whether users can anonymize provenance. - NO
- Whether population data is public globally. - Whether population data is public globally. - DEFER
- Whether destroyed cards remain visible. - Whether destroyed cards remain visible. - NO
#### ANSWER
- Binder.
- Card ownership.
- Finish/material/printing.
- Original opener.
- Opening date.
- Population.
- Trade history.
--- ---
@@ -916,6 +1400,10 @@ Questions to resolve later:
Trading is intentionally outside the first MVP but the data model may need to leave room for it. Trading is intentionally outside the first MVP but the data model may need to leave room for it.
#### ANSWER
DEFERRED FOR NOW
--- ---
## Tier 7 — MVP and Technology ## Tier 7 — MVP and Technology
@@ -955,6 +1443,10 @@ Questions to resolve:
The current scope reads more like a vertical slice designed to prove the unique and technically difficult parts of the product. The current scope reads more like a vertical slice designed to prove the unique and technically difficult parts of the product.
#### ANSWER
I'll iron this out as I go. I want it to be no multiplayer but fully locally functioning with like 50 cards.
--- ---
### 41. How many real cards does the first build actually need? ### 41. How many real cards does the first build actually need?
@@ -962,7 +1454,7 @@ The current scope reads more like a vertical slice designed to prove the unique
Current target: Current target:
- Around 50 real cards. - Around 50 real cards.
- Plus 4–6 filler cards, possibly inside or outside that count. - Plus 4–6 filler cards, possibly inside or outside that count. - DEFERRED
Questions to resolve: Questions to resolve:
@@ -970,10 +1462,15 @@ Questions to resolve:
- Could the first implementation use: - Could the first implementation use:
- 10 hero cards. - 10 hero cards.
- A smaller representative common/uncommon pool. - A smaller representative common/uncommon pool.
- Filler cards.
- When does the full ~50-card MVP set become necessary? - When does the full ~50-card MVP set become necessary?
- Should content production happen after the hard renderer is proven? - Should content production happen after the hard renderer is proven?
#### ANSWER
50 For now
a few of each category.
--- ---
### 42. Three.js vs Flutter Scene / Flutter GPU ### 42. Three.js vs Flutter Scene / Flutter GPU
@@ -1000,6 +1497,10 @@ The proposed hard test card is:
This decision should follow the spike rather than be made theoretically. This decision should follow the spike rather than be made theoretically.
#### ANSWER
Already decided. Three.js wins
--- ---
### 43. What is the exact web framework? ### 43. What is the exact web framework?
@@ -1019,6 +1520,10 @@ Questions to resolve after the renderer spike:
- Mobile-browser architecture. - Mobile-browser architecture.
- Whether the renderer lives as an isolated package. - Whether the renderer lives as an isolated package.
#### ANSWER
FOR TECH SPEC
--- ---
### 44. What is the shader/material architecture? ### 44. What is the shader/material architecture?
@@ -1043,6 +1548,10 @@ Still unresolved:
- Performance tiers. - Performance tiers.
- How closely multiple renderer implementations must match visually. - How closely multiple renderer implementations must match visually.
#### ANSWER
This is worked out in the card-harness spike
--- ---
### 45. How are static thumbnails generated? ### 45. How are static thumbnails generated?
@@ -1060,6 +1569,10 @@ Questions to resolve:
- How are holographic cards represented in static form? - How are holographic cards represented in static form?
- Can the same card contract drive both 3D and thumbnail generation? - Can the same card contract drive both 3D and thumbnail generation?
#### ANSWER
Can be spiked out later. I'm not married to the idea. I can always optimize later
--- ---
# Explicitly Deferred Topics # Explicitly Deferred Topics

View File

@@ -6,10 +6,19 @@
- Approved: September 7, 2026 - Approved: September 7, 2026
- Scope: card geometry, front-face materials, substrate distinction, foil, holographic response, and current finish-mask policy - Scope: card geometry, front-face materials, substrate distinction, foil, holographic response, and current finish-mask policy
- Follow-up approval: rigid pack prototype and artifact-free individual card inspection; retained as the foundation for the foil iteration below - Follow-up approval: rigid pack prototype and artifact-free individual card inspection; retained as the foundation for the foil iteration below
- Linen follow-up: `linen-relief-v1-2026-09-10`, visually approved September 10, 2026
- Metal and wood follow-up: `metal-wood-relief-v1-2026-09-10`, visually approved September 10, 2026
- Not yet approved: the new foil treatment/choreography, lighting presets, mobile performance, or wear - Not yet approved: the new foil treatment/choreography, lighting presets, mobile performance, or wear
These values represent the current visual baseline. Changes should be deliberate and compared against this revision rather than treated as incidental shader cleanup. These values represent the current visual baseline. Changes should be deliberate and compared against this revision rather than treated as incidental shader cleanup.
The approved September 10 depth follow-ups supersede the v4 linen, metal, and wood
surface responses below: raised-looking weave, recessed metal etching, and raised
wood grain with shader parallax, including Printed ink at oblique angles.
Paper, Plastic, card geometry, backs, and renderer settings retain the v4 baseline.
This visual approval is not
a mobile performance sign-off or approval of the remaining pack choreography.
## Geometry ## Geometry
The card is generated procedurally at runtime. Blender and GLB files are not required. The card is generated procedurally at runtime. Blender and GLB files are not required.
@@ -128,10 +137,21 @@ Finish and material are independent concepts, but combinations should exist only
- Roughness floor: `0.70` - Roughness floor: `0.70`
- Interlaced thread frequency: `52 x 73` - Interlaced thread frequency: `52 x 73`
- Relief amplitude: `0.005 * detail` - Raised weave height span: `0.010 * min(detail, 1.5)` scene units
- Reflection strength: `0.025` - Analytic thread slopes keep normal response readable at oblique angles
- Two procedural weave evaluations add bounded view-dependent parallax; artwork,
finish-mask, and wear UVs stay anchored
- Grazing denominator floor: `0.30`; detail is capped at `1.5` for linen relief
- Subpixel threads fade using screen-space derivatives
- Base reflection strength: `0.025`; broad thread-ridge highlight strength:
`0.025 + 0.055 * grazing`, modulated by resolved ridge height and surface detail
- Edge: `#a89772`, roughness `0.78`, metalness `0`, clear coat `0` - Edge: `#a89772`, roughness `0.78`, metalness `0`, clear coat `0`
This is shader relief, not displaced geometry: the card silhouette and physical
thickness are unchanged. The Surface detail control scales the effect, including
turning it off at zero. Printed ink retains the thread lighting without requiring
a foil finish; foil and holographic coating formulas are unchanged.
### Plastic ### Plastic
- Roughness ceiling: `0.25` - Roughness ceiling: `0.25`
@@ -142,9 +162,13 @@ Finish and material are independent concepts, but combinations should exist only
### Metal ### Metal
- Visually approved September 10, 2026
- Roughness ceiling: `0.20` - Roughness ceiling: `0.20`
- Artwork-driven etch: darker values are recessed more deeply - Artwork-driven etch: darker values are recessed more deeply
- Etch relief amplitude: `-0.0065 * depth * min(detail, 1.5)` - Etch relief amplitude: `-0.010 * depth * min(detail, 1.5)` (previously `-0.0065`)
- Bounded predictor/corrector parallax shifts artwork and finish mask together;
grazing denominator floor `0.35`, fading within `0.025` UV of the card edges
- Directional recess shadow strength: `0.22`, scaled by detail and local ridge depth
- Cavity shading: `0.12` on the metal underprint and `0.06` on the combined printed result - Cavity shading: `0.12` on the metal underprint and `0.06` on the combined printed result
- Metal underprint blend: `0.42` - Metal underprint blend: `0.42`
- Edge: `#a8adb7`, roughness `0.20`, metalness `0.86`, clear coat `0.18` - Edge: `#a8adb7`, roughness `0.20`, metalness `0.86`, clear coat `0.18`
@@ -158,11 +182,18 @@ Metal + Foil is a special intentional combination:
- Plating weight is capped at `0.34` - Plating weight is capped at `0.34`
- Must not turn the illustration into opaque gold - Must not turn the illustration into opaque gold
Parallax and directional recess shading add depth cues beyond the stronger relief.
The existing underprint tint and foil/plating recipes are retained; the finish
mask follows the shifted artwork. Card thickness and geometry are unchanged.
### Wood ### Wood
- Visually approved September 10, 2026
- Roughness floor: `0.48` - Roughness floor: `0.48`
- Warped longitudinal grain frequency: `170` - Warped longitudinal grain frequency: `170`
- Relief amplitude: `0.007 * detail` - Relief amplitude: `0.012 * min(detail, 1.5)` (previously `0.007 * detail`)
- Two procedural grain evaluations provide bounded parallax; grazing denominator
floor `0.35`. Artwork stays anchored; tint and relief use the same shifted grain.
- Warm tint blend: `0.25` - Warm tint blend: `0.25`
- Reflection strength: `0.09` - Reflection strength: `0.09`
- Edge: `#6b3f21`, roughness `0.56`, metalness `0`, clear coat `0.08` - Edge: `#6b3f21`, roughness `0.56`, metalness `0`, clear coat `0.08`

View File

@@ -66,6 +66,12 @@ Camera framing eases with the lift/exit so the deeper separation does not
unexpectedly enlarge the card. The edge mesh draws only extrusion side walls, not end caps beneath the unexpectedly enlarge the card. The edge mesh draws only extrusion side walls, not end caps beneath the
dedicated artwork faces, preventing depth-fighting bands at farther zoom distances. dedicated artwork faces, preventing depth-fighting bands at farther zoom distances.
Pack assets are loaded separately on first entry, with retry on failure. Pack assets are loaded separately on first entry, with retry on failure.
Before enabling pack interaction, textures are uploaded and shaders are compiled
for the pouch, hidden card fronts, universal backs, and both neutral/revealed edge
materials using the scene's lighting and environment. This preparation does not
draw or reveal cards. The loading state lasts until it finishes; failures release
the new pack resources and keep retry available. Returning to an already prepared
pack retains its state without repeating this preparation.
Wrapper deformation is coalesced to once per rendered frame, including touch dents Wrapper deformation is coalesced to once per rendered frame, including touch dents
and tear input. Fixed crease/crimp calculations are cached; unchanged pouch surfaces and tear input. Fixed crease/crimp calculations are cached; unchanged pouch surfaces
@@ -73,6 +79,9 @@ and ribbons do not recalculate normals/bounds or upload vertex buffers. Hidden
wrappers do not deform. Pack UI updates are also coalesced and only write changed wrappers do not deform. Pack UI updates are also coalesced and only write changed
values. These optimizations retain the same mesh resolution, deformation formulas, values. These optimizations retain the same mesh resolution, deformation formulas,
materials, lighting, pixel ratio, and antialiasing. materials, lighting, pixel ratio, and antialiasing.
Mint cards (`condition = 1`) bypass procedural wear calculations whose contribution
is zero. Worn cards use the original wear equations, and all finish/substrate
shading remains unchanged.
There is no sound, haptics, particles, cloth simulation, backend, rewards persistence, There is no sound, haptics, particles, cloth simulation, backend, rewards persistence,
pack progress persistence, or timeline/editor. This is a choreography proof, not an pack progress persistence, or timeline/editor. This is a choreography proof, not an
@@ -86,12 +95,26 @@ Finish and substrate are separate:
- Finish controls printed ink, foil, or holographic coating. - Finish controls printed ink, foil, or holographic coating.
- Substrate controls surface roughness/microtexture, underprint response, and edge/core appearance. - Substrate controls surface roughness/microtexture, underprint response, and edge/core appearance.
Paper uses fine, irregular fibers and a broad, weak highlight. Linen uses raised Paper uses fine, irregular fibers and a broad, weak highlight. Linen uses raised-looking
interlaced threads with small shaded recesses. Plastic has a smooth clear-coat interlaced threads with analytic slope lighting, a bounded two-sample procedural
highlight even in Printed ink mode. Wood uses warped longitudinal grain with parallax offset, and broad ridge highlights that remain visible in Printed ink
surface relief and a restrained warm tint. Metal retains its reflective at oblique angles. Only the weave shifts with the view: artwork and lettering stay
underprint and adds a shallow luminance-driven etch, with darker artwork anchored. Surface detail scales the effect; unresolved threads fade to avoid
recessed slightly more than lighter artwork. Procedural detail fades below shimmer. This is shader relief with no added geometry or change to the card's
silhouette, back, or thickness. This linen look was visually approved September 10,
2026 as `linen-relief-v1-2026-09-10`; its exact defaults are recorded in
[`CURRENT-DEFAULTS.md`](CURRENT-DEFAULTS.md#linen). No extra toggle or configuration
is required: selecting Linen applies it in Inspect, Lab, and the authored pack.
Plastic has a smooth clear-coat
highlight even in Printed ink mode. Wood uses raised longitudinal grain with
bounded procedural parallax, stronger relief, and a restrained warm tint.
The artwork stays anchored while grain shading and tint shift together.
Metal retains its reflective underprint and uses luminance-driven recesses:
bounded parallax shifts the artwork and finish mask together, with directional
groove shading to emphasize depth. The effect fades at the card edges.
Metal and wood depth were visually approved September 10, 2026 as
`metal-wood-relief-v1-2026-09-10`. Surface detail scales the relief; neither
material adds geometry or changes the card silhouette. Procedural detail fades below
pixel resolution to limit shimmer. pixel resolution to limit shimmer.
The universal card back uses fixed neutral material properties so front finish The universal card back uses fixed neutral material properties so front finish
and substrate choices do not spoil a pack reveal. and substrate choices do not spoil a pack reveal.
@@ -107,6 +130,11 @@ slightly raised champagne-metal polishing over the etched steel base.
- `blender_prototype/card_finish_material.py` - `blender_prototype/card_finish_material.py`
- `blender_prototype/render_premium_review.py` - `blender_prototype/render_premium_review.py`
The approved linen, metal, and wood depth follow-ups change runtime shader behavior,
not the source artwork, generated normal map, or Blender reference renders. The
asset manifest therefore retains its v4 reference revision; the scoped material
approvals and parameters are tracked in `CURRENT-DEFAULTS.md`.
The runtime shader and geometry are runtime implementations, not a claim of pixel-identical Blender output. The runtime shader and geometry are runtime implementations, not a claim of pixel-identical Blender output.
Live review removed only David's face, hand, and lamb ellipse cutouts. Color-based coverage and title-panel protection remain. Holographic color and angle response are preserved; reduced sheen applies only to foil. Live review removed only David's face, hand, and lamb ellipse cutouts. Color-based coverage and title-panel protection remain. Holographic color and angle response are preserved; reduced sheen applies only to foil.
@@ -131,8 +159,32 @@ npm test
These check exact pre-optimization wrapper geometry snapshots, surface update These check exact pre-optimization wrapper geometry snapshots, surface update
counts, per-frame input batching, pause/resume, pinch handoff, restart, reduced counts, per-frame input batching, pause/resume, pinch handoff, restart, reduced
motion, and ordered reveals. They are CPU/state checks, not a real-device GPU or motion, ordered reveals, GPU preparation, and failure cleanup. They are CPU/state
touch-latency benchmark; mobile performance still needs on-device validation. checks, not a real-device GPU or touch-latency benchmark; mobile performance still
needs on-device validation.
WebGL pixel parity and pack shader-preparation checks require a local headless
Chromium browser with WebGL2 support, installed project dependencies, and generated
reference assets (`npm run assets`):
```sh
SHADER_BROWSER=/absolute/path/to/chromium \
SHADER_BROWSER_KIND=chromium \
node scripts/shader-pixel-regression.mjs
```
The Chromium runner uses SwiftShader software WebGL. Browsers are not installed
automatically; a missing browser, missing WebGL support, timeout, pixel mismatch,
or unexpected shader program causes a nonzero exit rather than a skipped check.
The pixel check compares 360 combinations of substrate, finish, condition, angle,
and lighting against the current shader with unconditional wear evaluation,
verifying that the mint fast path remains equivalent. A separately hash-verified
v4 reference checks that Paper and Plastic remain unchanged and that the approved
Linen, Metal, and Wood iterations produce a visual difference. The pack check exercises opening,
all three reveals, and restart under point, directional, and spot lighting,
checking for new shader programs after preparation. Software WebGL can validate
pixel parity and program reuse, but its timings are not mobile GPU benchmarks.
The asset-generation script uses cross-platform Node APIs and works on Windows and Linux. The retained `export:blender` script is an optional reference utility and is not part of the application pipeline. The asset-generation script uses cross-platform Node APIs and works on Windows and Linux. The retained `export:blender` script is an optional reference utility and is not part of the application pipeline.

View File

@@ -231,3 +231,114 @@ test('reduced-motion opening keeps the existing 70 ms transition duration', (t)
step(1) step(1)
assert.equal(pack.state, 'stackReady') assert.equal(pack.state, 'stackReady')
}) })
test('GPU preparation uploads unique textures and compiles hidden fronts and both edge looks', async (t) => {
const { pack, canvas } = setupPack(t)
pack.setActive(false)
const scene = new THREE.Scene()
scene.environment = pack.options.environment
const backTexture = new THREE.Texture()
pack.options.backMaterial.map = backTexture
const uploaded = []
const compiledEdges = []
let reveals = 0
canvas.addEventListener('packreveal', () => reveals++)
const before = geometryHash(pack.wrapper)
let finishCompile
const firstCompile = new Promise((resolve) => { finishCompile = resolve })
const renderer = {
initTexture(texture) { uploaded.push(texture) },
async compileAsync(root, camera, targetScene) {
assert.equal(root, pack.root)
assert.equal(camera, pack.camera)
assert.equal(targetScene, scene)
assert.equal(root.visible, false)
assert.equal(pack.state, 'sealed')
assert.equal(pack.cards.some((card) => card.front.visible), false)
assert.ok(pack.cards.every((card) => root.getObjectById(card.front.id)))
compiledEdges.push(pack.cards.map((card) => card.edge.clearcoat))
if (compiledEdges.length === 1) await firstCompile
},
}
let ready = false
const preparation = pack.prepareGPU(renderer, scene).then(() => { ready = true })
await Promise.resolve()
assert.equal(ready, false)
finishCompile()
await preparation
assert.equal(uploaded.length, new Set(uploaded).size)
assert.equal(uploaded.length, 9, '4 artwork/mask textures, normal, environment, back and 2 wrapper maps')
for (const { artwork, mask } of Object.values(pack.options.textures)) {
assert.ok(uploaded.includes(artwork))
assert.ok(uploaded.includes(mask))
}
assert.ok(uploaded.includes(backTexture))
assert.ok(uploaded.includes(pack.options.normalMap))
assert.ok(uploaded.includes(scene.environment))
assert.deepEqual(compiledEdges, [[0.02, 0.02, 0.02], [0, 0.02, 0.18]])
assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
assert.equal(geometryHash(pack.wrapper), before)
assert.equal(reveals, 0)
})
test('failed reveal-material warmup restores neutral edges and can be retried', async (t) => {
const { pack } = setupPack(t)
pack.setActive(false)
let compilations = 0
const failure = new Error('GPU compilation failed')
const renderer = {
initTexture() {},
async compileAsync() {
if (++compilations === 2) throw failure
},
}
const scene = new THREE.Scene()
await assert.rejects(pack.prepareGPU(renderer, scene), (error) => error === failure)
assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
assert.equal(pack.root.visible, false)
assert.equal(pack.state, 'sealed')
await pack.prepareGPU(renderer, scene)
assert.equal(compilations, 4)
})
test('GPU upload errors propagate and preparation cannot mutate an active pack', async (t) => {
const { pack } = setupPack(t)
const failure = new Error('GPU upload failed')
let uploads = 0
const renderer = {
initTexture() { uploads++; throw failure },
async compileAsync() { assert.fail('Compilation must not run after upload failure') },
}
const scene = new THREE.Scene()
await assert.rejects(pack.prepareGPU(renderer, scene), /before activating/)
assert.equal(uploads, 0)
pack.setActive(false)
await assert.rejects(pack.prepareGPU(renderer, scene), (error) => error === failure)
})
test('pack cleanup disposes owned resources without disposing shared or caller-owned textures', (t) => {
const { pack } = setupPack(t)
const scene = new THREE.Scene()
scene.add(pack.root)
const shared = [
pack.options.backMaterial, pack.options.normalMap, pack.options.environment,
...Object.values(pack.options.textures).flatMap(({ artwork, mask }) => [artwork, mask]),
]
const sharedSpies = shared.map((resource) => t.mock.method(resource, 'dispose'))
const geometries = new Set()
const materials = new Set()
pack.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return
geometries.add(object.geometry)
materials.add(object.material)
})
materials.delete(pack.options.backMaterial)
const resources = [...geometries, ...materials,
...new Set(pack.wrapper.root.children.map((mesh) => mesh.material.map))]
const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose'))
pack.dispose()
assert.equal(pack.root.parent, null)
assert.equal(pack.root.visible, false)
assert.ok(ownedSpies.every((spy) => spy.mock.callCount() === 1))
assert.ok(sharedSpies.every((spy) => spy.mock.callCount() === 0))
})

View File

@@ -0,0 +1,127 @@
import * as THREE from 'three'
import { PackOpening, packContents } from '../src/packOpening.ts'
import { createStudioCubeTexture } from '../src/cardMaterial.ts'
export async function verifyPackPreparation() {
const startedAt = performance.now()
const loader = new THREE.TextureLoader()
const paths = ['david-front', 'david-finish-mask', 'timothy-front', 'timothy-finish-mask', 'card-back', 'linen-normal']
const textures = await Promise.all(paths.map((path) => loader.loadAsync(`/reference/${path}.png`)))
for (const index of [0, 2, 4]) textures[index].colorSpace = THREE.SRGBColorSpace
textures[5].wrapS = textures[5].wrapT = THREE.RepeatWrapping
const environment = createStudioCubeTexture()
const results = []
try {
for (const lightType of ['Point', 'Directional', 'Spot']) {
// A fresh context prevents the preceding pixel test from accidentally warming pack shaders.
const renderer = new THREE.WebGLRenderer({ antialias: false })
renderer.setSize(192, 264)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
const shaderErrors = []
renderer.debug.onShaderError = (gl, program, vertex, fragment) => {
shaderErrors.push([gl.getProgramInfoLog(program), gl.getShaderInfoLog(vertex),
gl.getShaderInfoLog(fragment)].join('\n'))
}
const scene = new THREE.Scene()
scene.background = new THREE.Color('#0b0e14')
scene.environment = environment
const lightPosition = new THREE.Vector3(2.2, 2.5, 4.4)
const light = lightType === 'Point' ? new THREE.PointLight('#fff0d0', 34, 20, 1.7)
: lightType === 'Directional' ? new THREE.DirectionalLight('#fff0d0', 5.4)
: new THREE.SpotLight('#fff0d0', 54, 20, THREE.MathUtils.degToRad(28), 0.55, 1.7)
light.position.copy(lightPosition)
scene.add(light)
if (light.target) scene.add(light.target)
const backMaterial = new THREE.MeshPhysicalMaterial({
map: textures[4], roughness: 0.42, metalness: 0, clearcoat: 0.2,
clearcoatRoughness: 0.24, normalMap: null, side: THREE.DoubleSide,
})
const pack = new PackOpening({
canvas: renderer.domElement,
textures: {
David: { artwork: textures[0], mask: textures[1] },
Timothy: { artwork: textures[2], mask: textures[3] },
},
backMaterial, normalMap: textures[5], environment, lightPosition, onChange() {},
})
pack.resize(192, 264)
const revealed = []
renderer.domElement.addEventListener('packreveal', (event) => revealed.push(event.detail))
const originalNow = performance.now
try {
const programsBeforePreparation = renderer.info.programs.length
await pack.prepareGPU(renderer, scene)
if (pack.root.visible || pack.state !== 'sealed' || revealed.length) {
throw new Error('Preparation changed visibility or reveal state')
}
const preparedPrograms = new Set(renderer.info.programs.map((program) => program.id))
if (!preparedPrograms.size) throw new Error('Preparation compiled no programs')
const texturesAfterPreparation = renderer.info.memory.textures
const unexpectedPrograms = new Map()
const visitedStates = new Set()
let renderedFrames = 0
let maximumTextureCount = texturesAfterPreparation
const textureGrowth = []
let now = originalNow.call(performance)
performance.now = () => now
function frame() {
pack.tick(now)
renderer.render(scene, pack.camera)
renderedFrames++
visitedStates.add(pack.state)
if (renderer.info.memory.textures > maximumTextureCount) {
textureGrowth.push({ state: pack.state, count: renderer.info.memory.textures })
}
maximumTextureCount = Math.max(maximumTextureCount, renderer.info.memory.textures)
for (const program of renderer.info.programs) {
if (!preparedPrograms.has(program.id) && !unexpectedPrograms.has(program.id)) {
unexpectedPrograms.set(program.id, { id: program.id, name: program.name, firstState: pack.state })
}
}
const gl = renderer.getContext()
if (shaderErrors.length || gl.isContextLost() || gl.getError() !== gl.NO_ERROR) {
throw new Error(`Pack WebGL rendering failed: ${shaderErrors.join('\n')}`)
}
}
scene.add(pack.root)
pack.setActive(true)
frame()
const durations = { opening: 3200, lifting: 620, revealing: 820, advancing: 650 }
for (let action = 0; action < 10 && pack.state !== 'complete'; action++) {
pack.primary()
const duration = durations[pack.state]
if (!duration) throw new Error(`Unexpected animated state: ${pack.state}`)
const started = now
for (const progress of [0, 0.1, 0.25, 0.4, 0.5, 0.65, 0.8, 0.9, 1.001]) {
now = started + duration * progress
frame()
}
}
if (pack.state !== 'complete' || JSON.stringify(revealed.map(({ fixture, finish, substrate }) =>
({ fixture, finish, substrate }))) !== JSON.stringify(packContents)) {
throw new Error('First reveal cycle did not complete in authored order')
}
pack.restart()
frame()
results.push({
lightType, passed: unexpectedPrograms.size === 0, programsBeforePreparation,
programsAfterPreparation: preparedPrograms.size,
programsAfterRevealAndRestart: renderer.info.programs.length,
unexpectedPrograms: [...unexpectedPrograms.values()], texturesAfterPreparation,
maximumTextureCount, textureGrowth, renderedFrames, visitedStates: [...visitedStates], revealed,
})
} finally {
performance.now = originalNow
pack.dispose()
backMaterial.dispose()
renderer.dispose()
renderer.forceContextLoss()
}
}
return { passed: results.every((result) => result.passed),
durationMs: Math.round(performance.now() - startedAt), results }
} finally {
for (const texture of [...textures, environment]) texture.dispose()
}
}

View File

@@ -0,0 +1,191 @@
import * as THREE from 'three'
import { createCardMaterial, createStudioCubeTexture, applyMaterialControls } from '../src/cardMaterial.ts'
import { createCardGeometry } from '../src/cardGeometry.ts'
import { verifyPackPreparation } from './shader-pack-preparation.mjs'
const width = 192
const height = 264
const substrates = ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood']
const finishes = ['Printed ink', 'Foil', 'Holographic']
const conditions = [1, 0.999, 0.7, 0]
const angles = [0, 75]
const lights = [
{ name: 'Point', mode: 0, position: [2.2, 2.5, 4.4] },
{ name: 'Directional', mode: 1, position: [-2.8, 3.1, 4.8] },
{ name: 'Spot', mode: 2, position: [3.6, 1.9, 3.6] },
]
async function compare() {
const started = performance.now()
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true })
renderer.setSize(width, height)
renderer.setPixelRatio(1)
renderer.setClearColor(0, 0)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
const compileErrors = []
renderer.debug.onShaderError = (gl, program, vertex, fragment) => {
compileErrors.push([gl.getProgramInfoLog(program), gl.getShaderInfoLog(vertex),
gl.getShaderInfoLog(fragment)].join('\n'))
}
const gl = renderer.getContext()
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
const gpu = {
version: gl.getParameter(gl.VERSION),
vendor: debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
renderer: debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
userAgent: navigator.userAgent,
}
const loader = new THREE.TextureLoader()
const [artwork, mask, normal, referenceSource] = await Promise.all([
loader.loadAsync('/reference/david-front.png'),
loader.loadAsync('/reference/david-finish-mask.png'),
loader.loadAsync('/reference/linen-normal.png'),
fetch('/__shader_reference').then((response) => response.json()),
])
artwork.colorSpace = THREE.SRGBColorSpace
mask.colorSpace = THREE.NoColorSpace
normal.colorSpace = THREE.NoColorSpace
normal.wrapS = normal.wrapT = THREE.RepeatWrapping
const environment = createStudioCubeTexture()
const candidate = createCardMaterial(artwork, mask, normal, environment, new THREE.Vector3())
const reference = candidate.clone()
reference.fragmentShader = referenceSource.fragmentShader
const baseline = candidate.clone()
baseline.fragmentShader = referenceSource.baselineShader
const edge = new THREE.MeshBasicMaterial({ color: '#9c8c69' })
const card = createCardGeometry(candidate, candidate, edge)
const front = card.getObjectByName('CARD_FRONT')
// Isolate the changed face shader; unchanged edge/back pixels must not dilute coverage.
card.getObjectByName('CARD_EDGE').visible = false
card.getObjectByName('CARD_BACK').visible = false
const scene = new THREE.Scene()
scene.add(card)
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100)
camera.position.set(0, 0, 6.4)
camera.lookAt(0, 0, 0)
const target = new THREE.WebGLRenderTarget(width, height, {
type: THREE.UnsignedByteType,
format: THREE.RGBAFormat,
depthBuffer: true,
stencilBuffer: false,
})
target.texture.colorSpace = THREE.SRGBColorSpace
const currentPixels = new Uint8Array(width * height * 4)
const originalPixels = new Uint8Array(currentPixels.length)
const baselinePixels = new Uint8Array(currentPixels.length)
function render(material, buffer) {
front.material = material
renderer.setRenderTarget(target)
renderer.render(scene, camera)
renderer.readRenderTargetPixels(target, 0, 0, width, height, buffer)
if (compileErrors.length) throw new Error(compileErrors.join('\n'))
if (gl.isContextLost()) throw new Error('WebGL context lost')
const error = gl.getError()
if (error !== gl.NO_ERROR) throw new Error(`WebGL error: ${error}`)
}
const failures = []
const summary = { cases: 0, comparedPixels: 0, differingPixels: 0, differingChannels: 0, maxChannelDelta: 0 }
const byCondition = Object.fromEntries(conditions.map((value) => [value,
{ cases: 0, differingPixels: 0, maxChannelDelta: 0 }]))
let minimumCoveredPixels = Infinity
let negativeControl
const baselineComparison = { unchangedCases: 0, differingPixels: 0, changedPixels: { Linen: 0, Metal: 0, Wood: 0 } }
try {
for (const substrate of substrates) for (const finish of finishes) {
for (const condition of conditions) for (const angle of angles) for (const light of lights) {
const controls = {
substrate, finish, condition, imperfectionSeed: 81251, finishStrength: 0.6,
roughness: 0.23, normalStrength: 0.14, environmentIntensity: 0.7,
}
for (const material of [candidate, reference, baseline]) {
applyMaterialControls(material, controls)
material.uniforms.lightPosition.value.set(...light.position)
material.uniforms.lightMode.value = light.mode
}
card.rotation.set(0, THREE.MathUtils.degToRad(angle), 0)
render(reference, originalPixels)
render(candidate, currentPixels)
let differingPixels = 0
let maxChannelDelta = 0
let coveredPixels = 0
for (let i = 0; i < currentPixels.length; i += 4) {
if (originalPixels[i + 3]) coveredPixels++
let different = false
for (let channel = 0; channel < 4; channel++) {
const delta = Math.abs(originalPixels[i + channel] - currentPixels[i + channel])
if (delta) {
different = true
summary.differingChannels++
maxChannelDelta = Math.max(maxChannelDelta, delta)
}
}
if (different) differingPixels++
}
minimumCoveredPixels = Math.min(minimumCoveredPixels, coveredPixels)
if (coveredPixels < width * height * 0.05) throw new Error('Insufficient rendered card coverage')
summary.cases++
summary.comparedPixels += width * height
summary.differingPixels += differingPixels
summary.maxChannelDelta = Math.max(summary.maxChannelDelta, maxChannelDelta)
byCondition[condition].cases++
byCondition[condition].differingPixels += differingPixels
byCondition[condition].maxChannelDelta = Math.max(byCondition[condition].maxChannelDelta, maxChannelDelta)
if (differingPixels && failures.length < 30) {
failures.push({ substrate, finish, condition, angle, light: light.name, differingPixels, maxChannelDelta })
}
render(baseline, baselinePixels)
let changed = 0
for (let i = 0; i < currentPixels.length; i += 4) {
if (currentPixels.subarray(i, i + 4).some((value, channel) => value !== baselinePixels[i + channel])) changed++
}
if (Object.hasOwn(baselineComparison.changedPixels, substrate)) {
baselineComparison.changedPixels[substrate] += changed
} else {
baselineComparison.unchangedCases++
baselineComparison.differingPixels += changed
}
if (!negativeControl && condition === 1 && angle === 0) {
candidate.uniforms.condition.value = 0
render(candidate, currentPixels)
let changed = 0
for (let i = 0; i < currentPixels.length; i += 4) {
if (currentPixels.subarray(i, i + 4).some((value, channel) => value !== originalPixels[i + channel])) changed++
}
if (changed < 100) throw new Error('Negative control failed: fully worn and mint unexpectedly match')
negativeControl = { mintVsFullyWornDifferingPixels: changed }
}
}
// Keep the browser event loop responsive, without unbounded animation frames.
await new Promise((resolve) => setTimeout(resolve, 0))
}
return {
passed: summary.differingPixels === 0 && summary.cases === 360
&& baselineComparison.differingPixels === 0
&& Object.values(baselineComparison.changedPixels).every(value => value > 0),
gpu, resolution: [width, height], substrates, finishes, conditions, angles, lights,
seed: 81251, tolerance: 0, ...summary, byCondition, minimumCoveredPixels, negativeControl,
baselineComparison, failures, durationMs: Math.round(performance.now() - started),
}
} finally {
target.dispose()
candidate.dispose()
reference.dispose()
baseline.dispose()
edge.dispose()
card.traverse((object) => object.geometry?.dispose())
for (const texture of [artwork, mask, normal, environment]) texture.dispose()
renderer.dispose()
}
}
try {
const report = await compare()
report.packPreparation = await verifyPackPreparation()
report.passed &&= report.packPreparation.passed
await fetch('/__shader_pixels', { method: 'POST', body: JSON.stringify(report) })
} catch (error) {
await fetch('/__shader_pixels', {
method: 'POST', body: JSON.stringify({ passed: false, error: String(error), stack: error.stack }),
})
}

View File

@@ -0,0 +1,126 @@
// Run: node scripts/shader-pixel-regression.mjs
// Optional: SHADER_BROWSER=/path/to/firefox SHADER_TIMEOUT_MS=180000
// Chromium: SHADER_BROWSER=/path/to/chrome-headless-shell SHADER_BROWSER_KIND=chromium
// Hosts without user namespaces: SHADER_NO_SANDBOX=1 (local test content only).
// No automation dependency: the browser POSTs WebGL readback results to a loopback Vite server.
// Also checks that prepared packs create no new programs through reveal and restart.
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { readFile, mkdir, writeFile, rm } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer } from 'vite'
import { originalFragment, baselineFragment, referenceCommit, referenceHash } from './shader-wear-reference.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const profile = resolve(root, 'scripts', `.shader-browser-${process.pid}`)
const timeoutMs = Number(process.env.SHADER_TIMEOUT_MS ?? 180000)
if (!Number.isFinite(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) {
throw new Error('SHADER_TIMEOUT_MS must be between 1000 and 600000')
}
const fragmentShader = originalFragment(await readFile(resolve(root, 'src/cardMaterial.ts'), 'utf8'))
const baselineShader = baselineFragment(root)
let resolveResult
let rejectResult
const result = new Promise((resolve, reject) => {
resolveResult = resolve
rejectResult = reject
})
// Attach a handler immediately; startup errors can precede awaiting the result.
result.catch(() => {})
let server
let browser
let timer
try {
await mkdir(profile)
await writeFile(resolve(profile, 'user.js'), [
'user_pref("webgl.force-enabled", true);',
'user_pref("webgl.disabled", false);',
'user_pref("browser.shell.checkDefaultBrowser", false);',
'user_pref("browser.startup.homepage_override.mstone", "ignore");',
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
].join('\n'))
server = await createServer({
root,
configFile: false,
cacheDir: resolve(profile, 'vite'),
logLevel: 'error',
server: { host: '127.0.0.1', port: 0, watch: null },
plugins: [{
name: 'shader-pixel-regression',
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url === '/__shader_reference') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ fragmentShader, baselineShader }))
} else if (req.url === '/__shader_pixels' && req.method === 'POST') {
let body = ''
req.on('data', (chunk) => {
body += chunk
if (body.length > 2_000_000) req.destroy()
})
req.on('end', () => {
try {
resolveResult(JSON.parse(body))
res.end('ok')
} catch (error) {
rejectResult(error)
res.statusCode = 400
res.end('Invalid JSON')
}
})
} else if (req.url === '/__shader_test') {
res.setHeader('Content-Type', 'text/html')
res.end('<!doctype html><title>Shader pixel regression</title><script type="module" src="/scripts/shader-pixel-client.mjs"></script>')
} else {
next()
}
})
},
}],
})
await server.listen()
const address = server.httpServer.address()
const url = `http://127.0.0.1:${address.port}/__shader_test`
const probe = await fetch(url)
if (!probe.ok) throw new Error(`Local test server failed: ${probe.status}`)
console.log(`Shader reference verified against ${referenceCommit} (${referenceHash})`)
console.log(`Serving ${url}; browser timeout ${timeoutMs}ms`)
timer = setTimeout(() => rejectResult(new Error('Browser pixel regression timed out')), timeoutMs)
const browserArgs = process.env.SHADER_BROWSER_KIND === 'chromium' ? [
'--headless', `--user-data-dir=${profile}`, '--no-first-run',
'--disable-background-networking', '--disable-dev-shm-usage',
'--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', url,
] : ['--headless', '--no-remote', '--profile', profile, url]
if (process.env.SHADER_BROWSER_KIND === 'chromium' && process.env.SHADER_NO_SANDBOX === '1') {
browserArgs.unshift('--no-sandbox')
}
browser = spawn(process.env.SHADER_BROWSER ?? '/usr/bin/firefox', browserArgs, {
env: { ...process.env, TMPDIR: profile, MOZ_HEADLESS: '1' },
stdio: ['ignore', 'ignore', 'pipe'],
})
let browserLog = ''
browser.stderr.on('data', (chunk) => { browserLog = (browserLog + chunk).slice(-12000) })
browser.once('error', rejectResult)
browser.once('exit', (code) => rejectResult(new Error(`Browser exited with ${code}\n${browserLog}`)))
let report
try {
report = await result
} catch (error) {
console.error(browserLog)
throw error
}
console.log(JSON.stringify({ referenceCommit, referenceHash, ...report }, null, 2))
if (!report.passed) process.exitCode = 1
} finally {
clearTimeout(timer)
if (browser?.pid && browser.exitCode === null && browser.signalCode === null) {
const exited = once(browser, 'exit')
browser.kill('SIGTERM')
const forceKill = setTimeout(() => browser.kill('SIGKILL'), 3000)
await exited
clearTimeout(forceKill)
}
await server?.close()
await rm(profile, { recursive: true, force: true })
}

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
// SHA-256 of the whitespace-normalized fragment shader at this pre-fast-path commit.
export const referenceCommit = '2cfa0ca690134aa4988f01f0170e9e202b608c1c'
export const referenceHash = '5490249511bf2511ac78b6ef3b061fc8ef12ed42b255fe567954c16d5559f9b4'
export function baselineFragment(root) {
const source = execFileSync('git', [
'show', `${referenceCommit}:spikes/card-harness/src/cardMaterial.ts`,
], { cwd: root, encoding: 'utf8' })
const fragment = source.match(/const fragmentShader = `([\s\S]*?)`/)?.[1]
assert.ok(fragment, 'Cannot find original card fragment shader')
const hash = createHash('sha256').update(fragment.replace(/\s+/g, '')).digest('hex')
assert.equal(hash, referenceHash, 'Original shader reference must retain its recorded hash')
return fragment
}
export function originalFragment(source) {
const fragment = source.match(/const fragmentShader = `([\s\S]*?)`/)?.[1]
assert.ok(fragment, 'Cannot find card fragment shader')
const gate = /float edgeWear = 0\.0;\s*float scratchWear = 0\.0;\s*float scuffWear = 0\.0;\s*if \(condition < 1\.0\) \{([\s\S]*?)\}\s*(?=float wearDamage)/
assert.ok(gate.test(fragment), 'Expected the mint-only wear fast path')
const original = fragment.replace(gate, (_, body) => body.replace(
/^(\s*)(edgeWear|scratchWear|scuffWear) =/gm, '$1float $2 =',
))
return original
}

View File

@@ -1,4 +1,5 @@
import * as THREE from 'three' import * as THREE from 'three'
import { cardSceneDimensions } from './cardGeometry'
export type FinishName = 'Printed ink' | 'Foil' | 'Holographic' export type FinishName = 'Printed ink' | 'Foil' | 'Holographic'
export type SubstrateName = 'Paper' | 'Linen' | 'Plastic' | 'Metal' | 'Wood' export type SubstrateName = 'Paper' | 'Linen' | 'Plastic' | 'Metal' | 'Wood'
@@ -98,6 +99,7 @@ const fragmentShader = `
uniform float normalStrength; uniform float normalStrength;
uniform float condition; uniform float condition;
uniform float imperfectionSeed; uniform float imperfectionSeed;
uniform vec2 cardFaceSize;
uniform int finishMode; uniform int finishMode;
uniform int substrateMode; uniform int substrateMode;
uniform int lightMode; uniform int lightMode;
@@ -132,6 +134,25 @@ const fragmentShader = `
return sin(phase) * visibility; return sin(phase) * visibility;
} }
// Height and analytic UV slopes keep thread ridges lit even at oblique angles.
vec3 linenRelief(vec2 uv, vec4 visibility) {
vec2 frequency = vec2(52.0, 73.0) * 6.2831853;
vec2 phase = uv * frequency;
vec2 thread = sin(phase) * visibility.xy;
vec2 crossing = sin(phase * 0.5) * visibility.zw;
float over = 0.5 + 0.5 * crossing.x * crossing.y;
vec2 threadSlope = cos(phase) * frequency * visibility.xy;
vec2 crossingSlope = cos(phase * 0.5) * frequency * 0.5 * visibility.zw;
vec2 overSlope = 0.5 * crossingSlope * crossing.yx;
vec2 slope = vec2((1.0 - over) * threadSlope.x, over * threadSlope.y)
+ (thread.y - thread.x) * overSlope;
return vec3(0.5 + 0.5 * mix(thread.x, thread.y, over), slope * 0.5);
}
float metalDepth(vec3 ink) {
return smoothstep(0.08, 0.92, 1.0 - dot(ink, vec3(0.2126, 0.7152, 0.0722)));
}
float woodGrain(vec2 uv) { float woodGrain(vec2 uv) {
float warp = fiberNoise(uv * vec2(4.0, 2.5)); float warp = fiberNoise(uv * vec2(4.0, 2.5));
float phase = uv.x * 170.0 + warp * 16.0 + sin(uv.y * 9.0) * 2.5; float phase = uv.x * 170.0 + warp * 16.0 + sin(uv.y * 9.0) * 2.5;
@@ -208,6 +229,29 @@ const fragmentShader = `
vec2 artworkUv = vUv; vec2 artworkUv = vUv;
vec4 artworkSample = texture2D(artwork, artworkUv); vec4 artworkSample = texture2D(artwork, artworkUv);
float mask = texture2D(finishMask, artworkUv).r; float mask = texture2D(finishMask, artworkUv).r;
float metalRelief = 0.010 * min(normalStrength / 0.14, 1.5);
float metalBoundary = 1.0;
if (substrateMode == 3) {
vec2 edgeDistance = min(vUv, 1.0 - vUv);
metalBoundary = smoothstep(0.0, 0.025, min(edgeDistance.x, edgeDistance.y));
vec3 view = normalize(cameraPosition - vWorldPosition);
vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)),
dot(view, normalize(vWorldBitangent)));
vec2 ray = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.35)
* metalRelief * metalBoundary / cardFaceSize;
float exposure = finishMode == 1 ? 1.0 - mask * 0.25 : 1.0;
float depth = metalDepth(artworkSample.rgb) * exposure;
// A bounded predictor/corrector adds recess parallax without ray marching.
vec2 predictedUv = clamp(vUv - ray * depth, vec2(0.0), vec2(1.0));
float predictedDepth = metalDepth(texture2D(artwork, predictedUv).rgb) * exposure;
artworkUv = clamp(vUv - ray * (depth + predictedDepth) * 0.5, vec2(0.0), vec2(1.0));
artworkSample = texture2D(artwork, artworkUv);
mask = texture2D(finishMask, artworkUv).r;
}
float edgeWear = 0.0;
float scratchWear = 0.0;
float scuffWear = 0.0;
if (condition < 1.0) {
float wearAmount = pow(clamp(1.0 - condition, 0.0, 1.0), 1.15); float wearAmount = pow(clamp(1.0 - condition, 0.0, 1.0), 1.15);
float edgeDistance = min( float edgeDistance = min(
min(artworkUv.x, 1.0 - artworkUv.x), min(artworkUv.x, 1.0 - artworkUv.x),
@@ -215,21 +259,25 @@ const fragmentShader = `
); );
float edgeNoise = fiberNoise(artworkUv * vec2(43.0, 61.0) + imperfectionSeed * 0.0007); float edgeNoise = fiberNoise(artworkUv * vec2(43.0, 61.0) + imperfectionSeed * 0.0007);
float edgeWidth = mix(0.0015, 0.032, wearAmount) * mix(0.65, 1.25, edgeNoise); float edgeWidth = mix(0.0015, 0.032, wearAmount) * mix(0.65, 1.25, edgeNoise);
float edgeWear = (1.0 - smoothstep(edgeWidth * 0.45, edgeWidth, edgeDistance)) * wearAmount; edgeWear = (1.0 - smoothstep(edgeWidth * 0.45, edgeWidth, edgeDistance)) * wearAmount;
float scratchWear = max( scratchWear = max(
scratchLine(artworkUv, 1.0), scratchLine(artworkUv, 1.0),
max(scratchLine(artworkUv, 2.0), scratchLine(artworkUv, 3.0)) max(scratchLine(artworkUv, 2.0), scratchLine(artworkUv, 3.0))
) * smoothstep(0.04, 0.48, wearAmount); ) * smoothstep(0.04, 0.48, wearAmount);
float scuffWear = max( scuffWear = max(
scuffMark(artworkUv, 1.0), scuffMark(artworkUv, 1.0),
scuffMark(artworkUv, 2.0) scuffMark(artworkUv, 2.0)
) * smoothstep(0.18, 0.82, wearAmount); ) * smoothstep(0.18, 0.82, wearAmount);
}
float wearDamage = max(edgeWear, max(scratchWear * 0.8, scuffWear * 0.55)); float wearDamage = max(edgeWear, max(scratchWear * 0.8, scuffWear * 0.55));
mask *= 1.0 - wearDamage * 0.82; mask *= 1.0 - wearDamage * 0.82;
vec3 normalSample = texture2D(normalMap, artworkUv * 2.2).xyz * 2.0 - 1.0; vec3 normalSample = texture2D(normalMap, artworkUv * 2.2).xyz * 2.0 - 1.0;
float detail = normalStrength / 0.14; float detail = normalStrength / 0.14;
float surfaceHeight = 0.0; float surfaceHeight = 0.0;
float surfaceShade = 1.0; float surfaceShade = 1.0;
vec2 linenSlope = vec2(0.0);
float linenRidge = 0.0;
float woodSurfaceGrain = 0.5;
float metalEtchDepth = 0.0; float metalEtchDepth = 0.0;
float metalEtchExposure = 1.0; float metalEtchExposure = 1.0;
if (substrateMode == 0) { if (substrateMode == 0) {
@@ -240,30 +288,45 @@ const fragmentShader = `
surfaceShade = 1.0 + fibers * 0.045 * min(detail, 1.5); surfaceShade = 1.0 + fibers * 0.045 * min(detail, 1.5);
} }
if (substrateMode == 1) { if (substrateMode == 1) {
vec2 thread = artworkUv * vec2(52.0, 73.0) * 6.2831853; vec2 phase = artworkUv * vec2(52.0, 73.0) * 6.2831853;
float warp = filteredWave(thread.x); vec2 footprint = fwidth(phase);
float weft = filteredWave(thread.y); vec4 visibility = 1.0 - smoothstep(vec4(0.7), vec4(3.0),
float overUnder = filteredWave(thread.x * 0.5) * filteredWave(thread.y * 0.5); vec4(footprint, footprint * 0.5));
float weave = mix(warp, weft, 0.5 + 0.5 * overUnder); float relief = 0.010 * min(detail, 1.5);
surfaceHeight = weave * 0.005 * detail; vec3 weave = linenRelief(artworkUv, visibility);
surfaceShade = 1.0 - (1.0 - weave) * 0.055 * min(detail, 1.5); vec3 view = normalize(cameraPosition - vWorldPosition);
vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)),
dot(view, normalize(vWorldBitangent)));
// Offset only the weave, not the illustration, mask or printed lettering.
vec2 parallax = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.3)
* (weave.x - 0.5) * relief / cardFaceSize;
weave = linenRelief(artworkUv + parallax, visibility);
linenSlope = weave.yz * relief / cardFaceSize;
linenRidge = smoothstep(0.45, 0.9, weave.x) * min(visibility.x, visibility.y) * min(detail, 1.5);
surfaceShade = 1.0 - (1.0 - weave.x) * 0.11 * min(detail, 1.5);
} }
if (substrateMode == 3) { if (substrateMode == 3) {
float artworkLuminance = dot(artworkSample.rgb, vec3(0.2126, 0.7152, 0.0722)); metalEtchDepth = metalDepth(artworkSample.rgb);
metalEtchDepth = smoothstep(0.08, 0.92, 1.0 - artworkLuminance);
if (finishMode == 1) { if (finishMode == 1) {
metalEtchExposure = 1.0 - mask * 0.25; metalEtchExposure = 1.0 - mask * 0.25;
} }
surfaceHeight = -metalEtchDepth * metalEtchExposure * 0.0065 * min(detail, 1.5); surfaceHeight = -metalEtchDepth * metalEtchExposure * metalRelief;
if (finishMode == 1) { if (finishMode == 1) {
surfaceHeight += mask * 0.0005 * min(detail, 1.5); surfaceHeight += mask * 0.0005 * min(detail, 1.5);
} }
surfaceShade = 1.0 - metalEtchDepth * metalEtchExposure * 0.035; surfaceShade = 1.0 - metalEtchDepth * metalEtchExposure * 0.035;
} }
if (substrateMode == 4) { if (substrateMode == 4) {
float relief = 0.012 * min(detail, 1.5);
vec3 view = normalize(cameraPosition - vWorldPosition);
vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)),
dot(view, normalize(vWorldBitangent)));
float grain = woodGrain(artworkUv); float grain = woodGrain(artworkUv);
surfaceHeight = grain * 0.007 * detail; vec2 parallax = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.35)
surfaceShade = 0.88 + grain * 0.12; * (grain - 0.5) * relief / cardFaceSize;
woodSurfaceGrain = woodGrain(artworkUv + parallax);
surfaceHeight = woodSurfaceGrain * relief;
surfaceShade = 0.88 + woodSurfaceGrain * 0.12;
} }
surfaceHeight -= scratchWear * 0.0018 + scuffWear * 0.0007; surfaceHeight -= scratchWear * 0.0018 + scuffWear * 0.0007;
float substrateNormalScale = substrateMode == 3 ? 0.06 : 0.12; float substrateNormalScale = substrateMode == 3 ? 0.06 : 0.12;
@@ -273,6 +336,10 @@ const fragmentShader = `
vWorldBitangent * normalSample.y * normalStrength * substrateNormalScale vWorldBitangent * normalSample.y * normalStrength * substrateNormalScale
); );
normal = reliefNormal(normal, surfaceHeight); normal = reliefNormal(normal, surfaceHeight);
if (substrateMode == 1) {
normal = normalize(normal - normalize(vWorldTangent) * linenSlope.x
- normalize(vWorldBitangent) * linenSlope.y);
}
vec3 viewDirection = normalize(cameraPosition - vWorldPosition); vec3 viewDirection = normalize(cameraPosition - vWorldPosition);
vec3 lightVector = lightPosition - vWorldPosition; vec3 lightVector = lightPosition - vWorldPosition;
@@ -316,6 +383,12 @@ const fragmentShader = `
float reflectionStrength = substrateMode == 1 ? 0.025 : (substrateMode == 4 ? 0.09 : 0.045); float reflectionStrength = substrateMode == 1 ? 0.025 : (substrateMode == 4 ? 0.09 : 0.045);
printedInk += lightColor * substrateSpecular * reflectionStrength; printedInk += lightColor * substrateSpecular * reflectionStrength;
} }
if (substrateMode == 1) {
float threadHighlight = pow(max(dot(normal, halfVector), 0.0), 24.0);
float grazing = 1.0 - max(dot(normalize(vWorldNormal), viewDirection), 0.0);
printedInk += lightColor * threadHighlight * diffuse * lightIntensity * attenuation
* linenRidge * (0.025 + 0.055 * grazing);
}
if (substrateMode == 2) { if (substrateMode == 2) {
float reflectedFraction = min(0.32, dielectricFresnel * environmentIntensity); float reflectedFraction = min(0.32, dielectricFresnel * environmentIntensity);
printedInk = printedInk * (1.0 - reflectedFraction) + environment * reflectedFraction; printedInk = printedInk * (1.0 - reflectedFraction) + environment * reflectedFraction;
@@ -324,6 +397,13 @@ const fragmentShader = `
if (substrateMode == 3) { if (substrateMode == 3) {
float visibleEtch = metalEtchDepth * metalEtchExposure; float visibleEtch = metalEtchDepth * metalEtchExposure;
float etchEdge = smoothstep(0.002, 0.045, fwidth(visibleEtch)); float etchEdge = smoothstep(0.002, 0.045, fwidth(visibleEtch));
vec2 alongLight = vec2(dot(lightDirection, normalize(vWorldTangent)),
dot(lightDirection, normalize(vWorldBitangent)));
vec2 ridgeUv = clamp(artworkUv + alongLight * metalRelief * metalBoundary / cardFaceSize,
vec2(0.0), vec2(1.0));
float ridgeDepth = metalDepth(texture2D(artwork, ridgeUv).rgb) * metalEtchExposure;
float recessShadow = smoothstep(0.02, 0.25, visibleEtch - ridgeDepth)
* min(detail, 1.5) * metalBoundary;
vec3 metalUnderprint = mix( vec3 metalUnderprint = mix(
artworkSample.rgb * vec3(0.72, 0.76, 0.82), artworkSample.rgb * vec3(0.72, 0.76, 0.82),
environment, environment,
@@ -334,10 +414,10 @@ const fragmentShader = `
printedInk = mix(printedInk, metalUnderprint, 0.42); printedInk = mix(printedInk, metalUnderprint, 0.42);
printedInk *= 1.0 - visibleEtch * 0.06; printedInk *= 1.0 - visibleEtch * 0.06;
printedInk += lightColor * etchEdge * 0.025; printedInk += lightColor * etchEdge * 0.025;
printedInk *= 1.0 - recessShadow * 0.22;
} }
if (substrateMode == 4) { if (substrateMode == 4) {
float grain = woodGrain(artworkUv); vec3 woodTint = mix(vec3(0.72, 0.40, 0.18), vec3(1.0, 0.86, 0.65), woodSurfaceGrain);
vec3 woodTint = mix(vec3(0.72, 0.40, 0.18), vec3(1.0, 0.86, 0.65), grain);
printedInk *= mix(vec3(1.0), woodTint, 0.25); printedInk *= mix(vec3(1.0), woodTint, 0.25);
} }
if (finishMode == 0) { if (finishMode == 0) {
@@ -455,6 +535,7 @@ export function createCardMaterial(
normalStrength: { value: 0.14 }, normalStrength: { value: 0.14 },
condition: { value: 1.0 }, condition: { value: 1.0 },
imperfectionSeed: { value: 81251 }, imperfectionSeed: { value: 81251 },
cardFaceSize: { value: new THREE.Vector2(cardSceneDimensions.width, cardSceneDimensions.height) },
finishMode: { value: 2 }, finishMode: { value: 2 },
substrateMode: { value: 0 }, substrateMode: { value: 0 },
lightMode: { value: 0 }, lightMode: { value: 0 },

View File

@@ -173,5 +173,12 @@ export function createFoilWrapper() {
strip.visible = detach < 1 strip.visible = detach < 1
} }
deform(0, 0, 0, new THREE.Vector3()) deform(0, 0, 0, new THREE.Vector3())
return { root, deform } function dispose() {
for (const { mesh } of sheets) mesh.geometry.dispose()
stripGeometry.dispose()
frontTexture.dispose()
backMaterial.map!.dispose()
for (const material of [frontMaterial, backMaterial, stripMaterial]) material.dispose()
}
return { root, deform, dispose }
} }

View File

@@ -720,6 +720,8 @@ async function preparePack() {
packLoading = true packLoading = true
packError = undefined packError = undefined
updatePackUI() updatePackUI()
let preparedPack: PackOpening | undefined
let textures: THREE.Texture[] = []
try { try {
// Separate texture ownership keeps local artwork, experiments and fixture disposal independent. // Separate texture ownership keeps local artwork, experiments and fixture disposal independent.
const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask, const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask,
@@ -732,12 +734,12 @@ async function preparePack() {
} }
throw failure.reason throw failure.reason
} }
const textures = results.map((result, index) => { textures = results.map((result, index) => {
if (result.status !== 'fulfilled') throw new Error(`Missing pack asset: ${paths[index]}`) if (result.status !== 'fulfilled') throw new Error(`Missing pack asset: ${paths[index]}`)
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace) configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
return result.value return result.value
}) })
pack = new PackOpening({ preparedPack = new PackOpening({
canvas, canvas,
textures: { textures: {
David: { artwork: textures[0], mask: textures[1] }, David: { artwork: textures[0], mask: textures[1] },
@@ -749,12 +751,24 @@ async function preparePack() {
lightPosition, lightPosition,
onChange: () => { packUIDirty = true }, onChange: () => { packUIDirty = true },
}) })
scene.add(pack.root) preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
pack.syncLighting(frontMaterial) let preparedEnvironment: THREE.Texture | null
pack.setActive(mode === 'Pack') let preparedLightType: LightType
pack.resize(canvas.clientWidth, canvas.clientHeight) do {
preparedEnvironment = scene.environment
preparedLightType = controls.lightType
preparedPack.syncLighting(frontMaterial)
await preparedPack.prepareGPU(renderer, scene)
} while (scene.environment !== preparedEnvironment || controls.lightType !== preparedLightType)
preparedPack.syncLighting(frontMaterial)
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
preparedPack.setActive(mode === 'Pack')
scene.add(preparedPack.root)
pack = preparedPack
} catch (error) { } catch (error) {
packError = `Pack assets unavailable: ${error instanceof Error ? error.message : String(error)}` preparedPack?.dispose()
for (const texture of textures) texture.dispose()
packError = `Pack unavailable: ${error instanceof Error ? error.message : String(error)}`
console.error('Could not prepare pack prototype', error) console.error('Could not prepare pack prototype', error)
} finally { } finally {
packLoading = false packLoading = false

View File

@@ -186,6 +186,54 @@ export class PackOpening {
} }
} }
async prepareGPU(renderer: THREE.WebGLRenderer, scene: THREE.Scene) {
if (this.active) throw new Error('Prepare pack GPU resources before activating the pack')
const textures = new Set<THREE.Texture>()
if (scene.environment) textures.add(scene.environment)
this.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return
const materials = Array.isArray(object.material) ? object.material : [object.material]
for (const material of materials) {
for (const value of Object.values(material)) {
if (value instanceof THREE.Texture) textures.add(value)
}
if (material instanceof THREE.ShaderMaterial) {
for (const uniform of Object.values(material.uniforms)) {
if (uniform.value instanceof THREE.Texture) textures.add(uniform.value)
}
}
}
})
for (const texture of textures) renderer.initTexture(texture)
// compileAsync visits hidden fronts without drawing them or changing reveal state.
await renderer.compileAsync(this.root, this.camera, scene)
try {
this.cards.forEach((card, index) => {
applyEdgeMaterialControls(card.edge, { ...packContents[index], condition: 1 })
})
await renderer.compileAsync(this.root, this.camera, scene)
} finally {
for (const card of this.cards) applyEdgeMaterialControls(card.edge, { substrate: 'Paper', condition: 1 })
}
}
dispose() {
this.clearPointers()
this.active = false
this.root.visible = false
this.root.removeFromParent()
this.wrapper.dispose()
const geometries = new Set<THREE.BufferGeometry>()
for (const card of this.cards) {
card.object.traverse((object) => {
if (object instanceof THREE.Mesh) geometries.add(object.geometry)
})
card.material.dispose()
card.edge.dispose()
}
for (const geometry of geometries) geometry.dispose()
}
setActive(active: boolean) { setActive(active: boolean) {
if (!active) this.pause(performance.now()) if (!active) this.pause(performance.now())
this.clearPointers() this.clearPointers()